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