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    #[serde(rename = "VERDICT_EXPLOITATION_CRITICAL")]
77    VerdictExploitationCritical,
78    #[serde(rename = "VERDICT_OBFUSCATED")]
79    VerdictObfuscated,
80    #[serde(rename = "VERDICT_DENY_LIST")]
81    VerdictDenyList,
82
83    // ─── Client-transport reasons ────────────────────────────────────────
84    #[serde(rename = "CLIENT_NETWORK_UNREACHABLE")]
85    ClientNetworkUnreachable,
86    #[serde(rename = "CLIENT_AUTH_FAILED")]
87    ClientAuthFailed,
88    #[serde(rename = "CLIENT_BEARER_MISSING")]
89    ClientBearerMissing,
90    #[serde(rename = "CLIENT_RATE_LIMITED")]
91    ClientRateLimited,
92
93    // ─── Domain — 404 from /api/v1/remediation/:eco/:name ───────────────
94    #[serde(rename = "REMEDIATION_NOT_FOUND")]
95    RemediationNotFound,
96}
97
98impl ReasonCode {
99    /// Canonical wire-format string — what App emits + what every other SDK
100    /// asserts on.
101    pub fn as_str(&self) -> &'static str {
102        match self {
103            ReasonCode::VerdictClean => "VERDICT_CLEAN",
104            ReasonCode::VerdictRecommendedVersionNewer => "VERDICT_RECOMMENDED_VERSION_NEWER",
105            ReasonCode::VerdictAbandoned => "VERDICT_ABANDONED",
106            ReasonCode::VerdictLowTrust => "VERDICT_LOW_TRUST",
107            ReasonCode::VerdictDegradedStale => "VERDICT_DEGRADED_STALE",
108            ReasonCode::VerdictHasRemediation => "VERDICT_HAS_REMEDIATION",
109            ReasonCode::VerdictKevListed => "VERDICT_KEV_LISTED",
110            ReasonCode::VerdictExploitationCritical => "VERDICT_EXPLOITATION_CRITICAL",
111            ReasonCode::VerdictObfuscated => "VERDICT_OBFUSCATED",
112            ReasonCode::VerdictDenyList => "VERDICT_DENY_LIST",
113            ReasonCode::ClientNetworkUnreachable => "CLIENT_NETWORK_UNREACHABLE",
114            ReasonCode::ClientAuthFailed => "CLIENT_AUTH_FAILED",
115            ReasonCode::ClientBearerMissing => "CLIENT_BEARER_MISSING",
116            ReasonCode::ClientRateLimited => "CLIENT_RATE_LIMITED",
117            ReasonCode::RemediationNotFound => "REMEDIATION_NOT_FOUND",
118        }
119    }
120}
121
122impl std::fmt::Display for ReasonCode {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.write_str(self.as_str())
125    }
126}
127
128/// All 15 canonical reason-code values — consumed by drift-check CI.
129pub const ALL_REASON_CODES: &[ReasonCode] = &[
130    ReasonCode::VerdictClean,
131    ReasonCode::VerdictRecommendedVersionNewer,
132    ReasonCode::VerdictAbandoned,
133    ReasonCode::VerdictLowTrust,
134    ReasonCode::VerdictDegradedStale,
135    ReasonCode::VerdictHasRemediation,
136    ReasonCode::VerdictKevListed,
137    ReasonCode::VerdictExploitationCritical,
138    ReasonCode::VerdictObfuscated,
139    ReasonCode::VerdictDenyList,
140    ReasonCode::ClientNetworkUnreachable,
141    ReasonCode::ClientAuthFailed,
142    ReasonCode::ClientBearerMissing,
143    ReasonCode::ClientRateLimited,
144    ReasonCode::RemediationNotFound,
145];
146
147/// `VerdictEnvelopeV1` — parsed shape of the `verdict-envelope.v1.json`
148/// schema. Top-level fields are required; rich sub-objects are sparse and
149/// `#[serde(default)]`-tolerant so the SDK can consume partial responses
150/// during cycle-N spec evolution without forcing a recompile.
151///
152/// Sister of:
153/// - sdk-js  `VerdictEnvelopeV1Schema` (zod)
154/// - sdk-py  no struct (dict[str, Any] in Python)
155/// - sdk-go  `map[string]any` (loose) — but Rust gets a typed struct.
156#[derive(Debug, Clone, Deserialize, Serialize)]
157pub struct VerdictEnvelopeV1 {
158    pub status: String,
159    pub reason_code: String,
160    pub human_message: String,
161    pub as_of: String,
162
163    #[serde(default)]
164    pub rich_data: Option<serde_json::Value>,
165    #[serde(default)]
166    pub remediation: Option<serde_json::Value>,
167    #[serde(default)]
168    pub exploitability: Option<serde_json::Value>,
169    #[serde(default)]
170    pub availability: Option<serde_json::Value>,
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn parses_minimal_envelope() {
179        let json = r#"{
180            "status": "ALLOW",
181            "reason_code": "VERDICT_CLEAN",
182            "human_message": "ok",
183            "as_of": "2026-05-28"
184        }"#;
185        let env: VerdictEnvelopeV1 = serde_json::from_str(json).unwrap();
186        assert_eq!(env.status, "ALLOW");
187        assert_eq!(env.reason_code, "VERDICT_CLEAN");
188        assert!(env.rich_data.is_none());
189        assert!(env.remediation.is_none());
190        assert!(env.exploitability.is_none());
191        assert!(env.availability.is_none());
192    }
193
194    #[test]
195    fn all_15_reason_codes_present() {
196        // Drift-check sister of sdk-py ALL_REASON_CODES + sdk-go AllReasonCodes.
197        assert_eq!(ALL_REASON_CODES.len(), 15);
198    }
199
200    #[test]
201    fn reason_code_string_roundtrip() {
202        for rc in ALL_REASON_CODES {
203            let s = serde_json::to_string(rc).unwrap();
204            let back: ReasonCode = serde_json::from_str(&s).unwrap();
205            assert_eq!(&back, rc);
206            // as_str() matches serde wire format (quoted JSON string).
207            assert_eq!(format!("\"{}\"", rc.as_str()), s);
208        }
209    }
210
211    #[test]
212    fn status_string_roundtrip() {
213        for st in [Status::Allow, Status::Warn, Status::Deny] {
214            let s = serde_json::to_string(&st).unwrap();
215            let back: Status = serde_json::from_str(&s).unwrap();
216            assert_eq!(back, st);
217            assert_eq!(format!("\"{}\"", st.as_str()), s);
218        }
219    }
220}