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}
48
49impl Default for Verdict {
50    fn default() -> Self {
51        Self {
52            verdict_id: String::new(),
53            verdict: String::new(),
54            source: String::new(),
55            confidence: 0.0,
56            composite_score: 0,
57            reasoning: String::new(),
58            similar_to: Vec::new(),
59            evidence_gaps: Vec::new(),
60            suggested_actions: Vec::new(),
61            data_freshness_at: None,
62            data_oldest_signal_at: None,
63            stale_since_at: None,
64            staleness_reason: None,
65            computed_at: None,
66            severity: None,
67            decision: None,
68        }
69    }
70}
71
72/// One package identity for policy-preview / scan requests.
73#[derive(Debug, Clone, Deserialize, Serialize)]
74pub struct PackageRef {
75    pub ecosystem: String,
76    pub name: String,
77    pub version: String,
78}
79
80/// Body of `POST /v1/customer/policy/preview` — packages + optional
81/// hypothetical policy override (JSON-shaped; YAML-source customers
82/// convert client-side).
83#[derive(Debug, Clone, Serialize)]
84pub struct PolicyPreviewRequest {
85    pub packages: Vec<PackageRef>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub policy: Option<serde_json::Value>,
88}
89
90/// Per-package decision returned from `/v1/customer/policy/preview` or
91/// embedded in audit entries.
92#[derive(Debug, Clone, Deserialize, Serialize, Default)]
93#[serde(default)]
94pub struct PolicyDecision {
95    pub ecosystem: String,
96    pub package: String,
97    pub version: String,
98    /// `ALLOW` | `DENY` | `WARN` | `INSUFFICIENT_DATA` | `RISK_ACCEPTANCE_REQUIRED`
99    pub decision: String,
100    pub reason: String,
101    pub verdict_id: Option<String>,
102    pub policy_rule_id: Option<String>,
103}
104
105/// Response from `POST /v1/customer/policy/preview`.
106#[derive(Debug, Clone, Deserialize, Serialize, Default)]
107#[serde(default)]
108pub struct PolicyPreviewResponse {
109    pub decisions: Vec<PolicyDecision>,
110}
111
112/// One audit log entry returned from `GET /v1/customer/audit`.
113#[derive(Debug, Clone, Deserialize, Serialize, Default)]
114#[serde(default)]
115pub struct AuditEntry {
116    pub request_id: String,
117    pub at: String,
118    pub ecosystem: String,
119    pub package: String,
120    pub version: String,
121    pub decision: String,
122    pub reason: String,
123    pub verdict_id: Option<String>,
124}
125
126/// Response from `GET /v1/customer/audit`.
127#[derive(Debug, Clone, Deserialize, Serialize, Default)]
128#[serde(default)]
129pub struct AuditResponse {
130    pub entries: Vec<AuditEntry>,
131    pub next_cursor: Option<String>,
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn parses_minimal_verdict() {
140        let json = r#"{
141            "verdict_id": "01JBYK000",
142            "verdict": "ALLOWED_NO_FINDINGS",
143            "source": "ALLOWED_NO_FINDINGS"
144        }"#;
145        let v: Verdict = serde_json::from_str(json).unwrap();
146        assert_eq!(v.verdict_id, "01JBYK000");
147        assert_eq!(v.verdict, "ALLOWED_NO_FINDINGS");
148        assert_eq!(v.confidence, 0.0);
149        assert!(v.similar_to.is_empty());
150    }
151
152    #[test]
153    fn parses_full_verdict() {
154        let json = r#"{
155            "verdict_id": "01JBYK001",
156            "verdict": "VECTOR_VERDICT",
157            "source": "VECTOR_VERDICT",
158            "confidence": 0.98,
159            "composite_score": 92,
160            "reasoning": "Confirmed malware",
161            "similar_to": ["01JBYK999"],
162            "evidence_gaps": [],
163            "suggested_actions": ["DENY across customers"],
164            "data_freshness_at": "2026-05-21T10:00:00Z",
165            "computed_at": "2026-05-21T10:01:00Z"
166        }"#;
167        let v: Verdict = serde_json::from_str(json).unwrap();
168        assert_eq!(v.composite_score, 92);
169        assert_eq!(v.confidence, 0.98);
170        assert_eq!(v.similar_to.len(), 1);
171        assert_eq!(v.suggested_actions[0], "DENY across customers");
172    }
173
174    #[test]
175    fn parses_policy_preview_response() {
176        let json = r#"{
177            "decisions": [
178                {"ecosystem":"npm","package":"left-pad","version":"1.3.0","decision":"ALLOW","reason":"ok"},
179                {"ecosystem":"npm","package":"event-stream","version":"3.3.6","decision":"DENY","reason":"malware","verdict_id":"01JBYK999"}
180            ]
181        }"#;
182        let resp: PolicyPreviewResponse = serde_json::from_str(json).unwrap();
183        assert_eq!(resp.decisions.len(), 2);
184        assert_eq!(resp.decisions[0].decision, "ALLOW");
185        assert_eq!(resp.decisions[1].decision, "DENY");
186        assert_eq!(resp.decisions[1].verdict_id.as_deref(), Some("01JBYK999"));
187    }
188
189    #[test]
190    fn parses_audit_response_with_cursor() {
191        let json = r#"{
192            "entries": [
193                {"request_id":"req-1","at":"2026-05-22T10:00:00Z","ecosystem":"npm","package":"lodash","version":"4.17.21","decision":"ALLOW","reason":"ok"}
194            ],
195            "next_cursor": "abc123"
196        }"#;
197        let resp: AuditResponse = serde_json::from_str(json).unwrap();
198        assert_eq!(resp.entries.len(), 1);
199        assert_eq!(resp.next_cursor.as_deref(), Some("abc123"));
200    }
201
202    #[test]
203    fn empty_audit_response_is_valid() {
204        let json = r#"{"entries": []}"#;
205        let resp: AuditResponse = serde_json::from_str(json).unwrap();
206        assert!(resp.entries.is_empty());
207        assert!(resp.next_cursor.is_none());
208    }
209
210    #[test]
211    fn policy_preview_request_omits_none_policy() {
212        let req = PolicyPreviewRequest {
213            packages: vec![PackageRef {
214                ecosystem: "npm".to_string(),
215                name: "lodash".to_string(),
216                version: "4.17.21".to_string(),
217            }],
218            policy: None,
219        };
220        let json = serde_json::to_string(&req).unwrap();
221        // None policy should not appear in serialized output
222        assert!(!json.contains("policy"));
223        assert!(json.contains("lodash"));
224    }
225
226    #[test]
227    fn policy_preview_request_emits_policy_when_some() {
228        let req = PolicyPreviewRequest {
229            packages: vec![],
230            policy: Some(serde_json::json!({"rules": []})),
231        };
232        let json = serde_json::to_string(&req).unwrap();
233        assert!(json.contains("\"policy\""));
234        assert!(json.contains("\"rules\""));
235    }
236
237    #[test]
238    fn round_trips_via_json() {
239        let v = Verdict {
240            verdict_id: "01JBYK002".to_string(),
241            verdict: "INSUFFICIENT_DATA".to_string(),
242            source: "INSUFFICIENT_DATA".to_string(),
243            stale_since_at: Some("2026-04-21T00:00:00Z".to_string()),
244            staleness_reason: Some("upstream silent >30d".to_string()),
245            ..Default::default()
246        };
247        let s = serde_json::to_string(&v).unwrap();
248        let parsed: Verdict = serde_json::from_str(&s).unwrap();
249        assert_eq!(parsed.verdict_id, "01JBYK002");
250        assert_eq!(parsed.stale_since_at.as_deref(), Some("2026-04-21T00:00:00Z"));
251    }
252
253    // ─── Lane-2 M1 — severity + decision carrying ──────────────────────
254
255    #[test]
256    fn verdict_round_trips_severity_and_decision() {
257        let v = Verdict {
258            verdict_id: "01JM1S001".to_string(),
259            verdict: "VECTOR_VERDICT".to_string(),
260            source: "VECTOR_VERDICT".to_string(),
261            severity: Some("HIGH".to_string()),
262            decision: Some("DENY".to_string()),
263            ..Default::default()
264        };
265        let s = serde_json::to_string(&v).unwrap();
266        let parsed: Verdict = serde_json::from_str(&s).unwrap();
267        assert_eq!(parsed.severity.as_deref(), Some("HIGH"));
268        assert_eq!(parsed.decision.as_deref(), Some("DENY"));
269    }
270
271    #[test]
272    fn verdict_tolerates_missing_severity_and_decision() {
273        // Pre-M1 payload shape — no severity/decision fields. Must still parse
274        // via serde-default tolerance per the struct's `#[serde(default)]`.
275        let pre_m1_json = r#"{
276            "verdict_id": "01JM1S002",
277            "verdict": "ALLOWED_NO_FINDINGS",
278            "source": "ALLOWED_NO_FINDINGS",
279            "confidence": 0.95,
280            "composite_score": 8,
281            "reasoning": "",
282            "similar_to": [],
283            "evidence_gaps": [],
284            "suggested_actions": []
285        }"#;
286        let v: Verdict = serde_json::from_str(pre_m1_json).expect("pre-M1 shape must still parse");
287        assert!(v.severity.is_none());
288        assert!(v.decision.is_none());
289    }
290
291    #[test]
292    fn verdict_decision_canonical_values_match_js_py_go() {
293        // Lane-2 M1 acceptance: decision values match js/py/go SDK envelope.
294        // Schema-locked set: ALLOW | WARN | DENY | RISK_ACCEPTANCE_REQUIRED.
295        for d in ["ALLOW", "WARN", "DENY", "RISK_ACCEPTANCE_REQUIRED"] {
296            let v = Verdict {
297                decision: Some(d.to_string()),
298                ..Default::default()
299            };
300            let s = serde_json::to_string(&v).unwrap();
301            assert!(s.contains(&format!("\"decision\":\"{}\"", d)));
302        }
303    }
304
305    #[test]
306    fn verdict_severity_canonical_values_match_cleanlib_core() {
307        // Sister of `cleanlib-core::Severity` enum: NONE | LOW | MEDIUM | HIGH | CRITICAL.
308        for sev in ["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"] {
309            let v = Verdict {
310                severity: Some(sev.to_string()),
311                ..Default::default()
312            };
313            let s = serde_json::to_string(&v).unwrap();
314            assert!(s.contains(&format!("\"severity\":\"{}\"", sev)));
315        }
316    }
317}