Skip to main content

cleanlib_client/
envelope.rs

1//! `VerdictEnvelopeV1` + `ReasonCode` — schema-locked mirror of
2//! `@cleanstart/cleanlib-sdk@0.4.1` (tarball sha-1
3//! `b5f00c160907a6ea1f490f14a9bda6f6de34b8b6`).
4//!
5//! Sister of:
6//! - sdk-js  `dist/reason-codes.js` + `dist/verdict-envelope.schema.json`
7//! - sdk-py  `cleanlib_sdk/reason_codes.py`
8//! - sdk-go  `reason_codes.go`
9//!
10//! Drift between any of the four SDK consumers is a CI failure;
11//! `cleanlib-contract-fixtures` v1.0.0 verifies byte-identical
12//! `(status, reason_code)` across all 4 implementations.
13//!
14//! Freshness-precedence rule (ratified 2026-05-28; binding via App dispatch §4
15//! + Client dispatch §2.3): when the substance-driving signal's
16//! `availability == "degraded_stale"`, the substance-derived status tier is
17//! PRESERVED and the `reason_code` OVERRIDES to `VERDICT_DEGRADED_STALE`.
18//! Server-side `VERDICT_DEGRADED_STALE` is architecturally distinct from the
19//! client-side `LIVE_DEGRADED` cache-fallback state (extension status bar +
20//! Cli7 offline mode).
21
22use serde::{Deserialize, Serialize};
23
24/// Tri-state envelope status tier — sister of sdk-js Literal type
25/// `'ALLOW' | 'WARN' | 'DENY'`.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
27pub enum Status {
28    #[serde(rename = "ALLOW")]
29    Allow,
30    #[serde(rename = "WARN")]
31    Warn,
32    #[serde(rename = "DENY")]
33    Deny,
34}
35
36impl Status {
37    /// Canonical wire-format string — what App emits + what every other SDK
38    /// asserts on.
39    pub fn as_str(&self) -> &'static str {
40        match self {
41            Status::Allow => "ALLOW",
42            Status::Warn => "WARN",
43            Status::Deny => "DENY",
44        }
45    }
46}
47
48impl std::fmt::Display for Status {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.write_str(self.as_str())
51    }
52}
53
54/// Canonical 15-value `ReasonCode` registry — Rust mirror of sdk-js v0.4.1
55/// `dist/reason-codes.js`. Drift = CI failure.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
57pub enum ReasonCode {
58    // ─── Status outcomes (verdict-tier reasons) ──────────────────────────
59    #[serde(rename = "VERDICT_CLEAN")]
60    VerdictClean,
61    #[serde(rename = "VERDICT_RECOMMENDED_VERSION_NEWER")]
62    VerdictRecommendedVersionNewer,
63    #[serde(rename = "VERDICT_ABANDONED")]
64    VerdictAbandoned,
65    #[serde(rename = "VERDICT_LOW_TRUST")]
66    VerdictLowTrust,
67    /// Server-side substrate freshness — substance-derived tier preserved,
68    /// reason overridden to surface staleness. Distinct from client-side
69    /// `LIVE_DEGRADED` cache-fallback (extension status bar; Cli7 offline mode).
70    #[serde(rename = "VERDICT_DEGRADED_STALE")]
71    VerdictDegradedStale,
72    #[serde(rename = "VERDICT_HAS_REMEDIATION")]
73    VerdictHasRemediation,
74    #[serde(rename = "VERDICT_KEV_LISTED")]
75    VerdictKevListed,
76    /// CLEANLIB-176: package named in ransomware campaigns
77    /// (`CVE_FINDING_ON_RANSOMWARE` source projection). Semantically distinct
78    /// from KEV — a CVE can be ransomware-linked without being CISA-KEV-listed
79    /// and vice-versa; conflating them defeats the 176 differentiation goal.
80    /// Cross-SDK + Threat ratification PENDING (see CLEANLIB-176 comment 658233).
81    #[serde(rename = "VERDICT_RANSOMWARE_LISTED")]
82    VerdictRansomwareListed,
83    #[serde(rename = "VERDICT_EXPLOITATION_CRITICAL")]
84    VerdictExploitationCritical,
85    /// CLEANLIB-176: confirmed/likely-malicious package (`MALICIOUS_TRIAGE`
86    /// source projection — typosquat / backdoor / protestware). Distinct from
87    /// `VERDICT_EXPLOITATION_CRITICAL` (a *CVE* being actively exploited):
88    /// malicious means remove + audit + rotate, not upgrade.
89    /// Cross-SDK + Threat ratification PENDING (see CLEANLIB-176 comment 658233).
90    #[serde(rename = "VERDICT_MALICIOUS")]
91    VerdictMalicious,
92    #[serde(rename = "VERDICT_OBFUSCATED")]
93    VerdictObfuscated,
94    #[serde(rename = "VERDICT_DENY_LIST")]
95    VerdictDenyList,
96
97    // ─── Client-transport reasons ────────────────────────────────────────
98    #[serde(rename = "CLIENT_NETWORK_UNREACHABLE")]
99    ClientNetworkUnreachable,
100    #[serde(rename = "CLIENT_AUTH_FAILED")]
101    ClientAuthFailed,
102    #[serde(rename = "CLIENT_BEARER_MISSING")]
103    ClientBearerMissing,
104    #[serde(rename = "CLIENT_RATE_LIMITED")]
105    ClientRateLimited,
106
107    // ─── Domain — 404 from /api/v1/remediation/:eco/:name ───────────────
108    #[serde(rename = "REMEDIATION_NOT_FOUND")]
109    RemediationNotFound,
110}
111
112impl ReasonCode {
113    /// Canonical wire-format string — what App emits + what every other SDK
114    /// asserts on.
115    pub fn as_str(&self) -> &'static str {
116        match self {
117            ReasonCode::VerdictClean => "VERDICT_CLEAN",
118            ReasonCode::VerdictRecommendedVersionNewer => "VERDICT_RECOMMENDED_VERSION_NEWER",
119            ReasonCode::VerdictAbandoned => "VERDICT_ABANDONED",
120            ReasonCode::VerdictLowTrust => "VERDICT_LOW_TRUST",
121            ReasonCode::VerdictDegradedStale => "VERDICT_DEGRADED_STALE",
122            ReasonCode::VerdictHasRemediation => "VERDICT_HAS_REMEDIATION",
123            ReasonCode::VerdictKevListed => "VERDICT_KEV_LISTED",
124            ReasonCode::VerdictRansomwareListed => "VERDICT_RANSOMWARE_LISTED",
125            ReasonCode::VerdictExploitationCritical => "VERDICT_EXPLOITATION_CRITICAL",
126            ReasonCode::VerdictMalicious => "VERDICT_MALICIOUS",
127            ReasonCode::VerdictObfuscated => "VERDICT_OBFUSCATED",
128            ReasonCode::VerdictDenyList => "VERDICT_DENY_LIST",
129            ReasonCode::ClientNetworkUnreachable => "CLIENT_NETWORK_UNREACHABLE",
130            ReasonCode::ClientAuthFailed => "CLIENT_AUTH_FAILED",
131            ReasonCode::ClientBearerMissing => "CLIENT_BEARER_MISSING",
132            ReasonCode::ClientRateLimited => "CLIENT_RATE_LIMITED",
133            ReasonCode::RemediationNotFound => "REMEDIATION_NOT_FOUND",
134        }
135    }
136}
137
138impl std::fmt::Display for ReasonCode {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.write_str(self.as_str())
141    }
142}
143
144/// All 17 canonical reason-code values — consumed by drift-check CI.
145/// CLEANLIB-176 added `VERDICT_RANSOMWARE_LISTED` + `VERDICT_MALICIOUS`
146/// (was 15); the three external SDKs (js/py/go) land these in coordinated
147/// follow-on PRs referencing the monorepo SHA (dispatch §6.2).
148pub const ALL_REASON_CODES: &[ReasonCode] = &[
149    ReasonCode::VerdictClean,
150    ReasonCode::VerdictRecommendedVersionNewer,
151    ReasonCode::VerdictAbandoned,
152    ReasonCode::VerdictLowTrust,
153    ReasonCode::VerdictDegradedStale,
154    ReasonCode::VerdictHasRemediation,
155    ReasonCode::VerdictKevListed,
156    ReasonCode::VerdictRansomwareListed,
157    ReasonCode::VerdictExploitationCritical,
158    ReasonCode::VerdictMalicious,
159    ReasonCode::VerdictObfuscated,
160    ReasonCode::VerdictDenyList,
161    ReasonCode::ClientNetworkUnreachable,
162    ReasonCode::ClientAuthFailed,
163    ReasonCode::ClientBearerMissing,
164    ReasonCode::ClientRateLimited,
165    ReasonCode::RemediationNotFound,
166];
167
168/// Canonical wire-strings for the verdict `source` field — Rust mirror of
169/// `cleanlib_core::VerdictSource` SCREAMING_SNAKE serde output. The first four
170/// are the cycle-≤16 originals; the last four are the CLEANLIB-176 projection
171/// variants that let customers separate "has CVEs" (upgrade) from "actively
172/// exploited" (KEV) / "ransomware-linked" / "malicious" (remove+audit+rotate).
173/// Used by [`crate::verdict_to_envelope_v1`] to refine the reason_code by
174/// source while preserving the label-derived status tier.
175pub const ALL_VERDICT_SOURCES: &[&str] = &[
176    "ALLOWED_NO_FINDINGS",
177    "VECTOR_VERDICT",
178    "DM_THRESHOLD_BLOCK",
179    "INSUFFICIENT_DATA",
180    "CVE_FINDING",
181    "CVE_FINDING_ON_KEV",
182    "CVE_FINDING_ON_RANSOMWARE",
183    "MALICIOUS_TRIAGE",
184];
185
186/// `VerdictEnvelopeV1` — parsed shape of the `verdict-envelope.v1.json`
187/// schema. Top-level fields are required; rich sub-objects are sparse and
188/// `#[serde(default)]`-tolerant so the SDK can consume partial responses
189/// during cycle-N spec evolution without forcing a recompile.
190///
191/// Sister of:
192/// - sdk-js  `VerdictEnvelopeV1Schema` (zod)
193/// - sdk-py  no struct (dict[str, Any] in Python)
194/// - sdk-go  `map[string]any` (loose) — but Rust gets a typed struct.
195#[derive(Debug, Clone, Deserialize, Serialize)]
196pub struct VerdictEnvelopeV1 {
197    pub status: String,
198    pub reason_code: String,
199    pub human_message: String,
200    pub as_of: String,
201
202    #[serde(default)]
203    pub rich_data: Option<serde_json::Value>,
204    #[serde(default)]
205    pub remediation: Option<serde_json::Value>,
206    #[serde(default)]
207    pub exploitability: Option<serde_json::Value>,
208    /// CLEANLIB-104 App-3.1 Gate M3: non-Optional per the design doc §3.3
209    /// exit criterion ("derive_status.rs line 57 stops using as_ref on
210    /// availability"). `#[serde(default)]` gives `Value::Null` on absent-key
211    /// payloads — fail-open: consumers see a defaulted block, not an
212    /// unwrap-panic. `derive_status.rs` uses `Some(&envelope.availability)`
213    /// against the untyped JSON-Value helpers (see line 57).
214    ///
215    /// `skip_serializing_if = "Value::is_null"` preserves the wire byte-
216    /// identity on happy-path envelopes: when the adapter passes through a
217    /// default `AvailabilityBlock` (degraded_stale=false), the envelope
218    /// serializes as `Null` and the field is omitted — matching the pre-M3
219    /// `Option<Value>::None` shape.
220    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
221    pub availability: serde_json::Value,
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn parses_minimal_envelope() {
230        let json = r#"{
231            "status": "ALLOW",
232            "reason_code": "VERDICT_CLEAN",
233            "human_message": "ok",
234            "as_of": "2026-05-28"
235        }"#;
236        let env: VerdictEnvelopeV1 = serde_json::from_str(json).unwrap();
237        assert_eq!(env.status, "ALLOW");
238        assert_eq!(env.reason_code, "VERDICT_CLEAN");
239        assert!(env.rich_data.is_none());
240        assert!(env.remediation.is_none());
241        assert!(env.exploitability.is_none());
242        // CLEANLIB-104 M3: availability is non-Optional; absent JSON key
243        // deserializes to `Value::Null` (the serde default for
244        // `serde_json::Value`).
245        assert!(env.availability.is_null());
246    }
247
248    #[test]
249    fn all_17_reason_codes_present() {
250        // Drift-check sister of sdk-py ALL_REASON_CODES + sdk-go AllReasonCodes.
251        // CLEANLIB-176: 15 → 17 (+VERDICT_RANSOMWARE_LISTED +VERDICT_MALICIOUS).
252        assert_eq!(ALL_REASON_CODES.len(), 17);
253    }
254
255    #[test]
256    fn all_verdict_sources_recognized() {
257        // CLEANLIB-176: the SDK must recognize all 8 wire-strings for the
258        // verdict `source` field (4 originals + 4 projection variants) so it
259        // never silently misclassifies a known source as unknown.
260        assert_eq!(ALL_VERDICT_SOURCES.len(), 8);
261        assert!(ALL_VERDICT_SOURCES.contains(&"CVE_FINDING_ON_KEV"));
262        assert!(ALL_VERDICT_SOURCES.contains(&"CVE_FINDING_ON_RANSOMWARE"));
263        assert!(ALL_VERDICT_SOURCES.contains(&"MALICIOUS_TRIAGE"));
264    }
265
266    #[test]
267    fn reason_code_string_roundtrip() {
268        for rc in ALL_REASON_CODES {
269            let s = serde_json::to_string(rc).unwrap();
270            let back: ReasonCode = serde_json::from_str(&s).unwrap();
271            assert_eq!(&back, rc);
272            // as_str() matches serde wire format (quoted JSON string).
273            assert_eq!(format!("\"{}\"", rc.as_str()), s);
274        }
275    }
276
277    #[test]
278    fn status_string_roundtrip() {
279        for st in [Status::Allow, Status::Warn, Status::Deny] {
280            let s = serde_json::to_string(&st).unwrap();
281            let back: Status = serde_json::from_str(&s).unwrap();
282            assert_eq!(back, st);
283            assert_eq!(format!("\"{}\"", st.as_str()), s);
284        }
285    }
286}