cleanlib_client/customer_state.rs
1//! CLEANLIB-178 — canonical customer-facing state taxonomy (Rust surfaces).
2//!
3//! Single source of truth for the **8 customer states** that the malicious-vs-
4//! vulnerable cluster (CLEANLIB-176 wire-shape + 177 policy overlay + 178
5//! aesthetic) surfaces. The design contract lives at
6//! `decisions/2026-06-12-cleanlib-178-state-aesthetic-mapping.md`; this module
7//! is its executable form for the Rust render surfaces — `cleanlib-cli`,
8//! `cleanlib-lsp` (CLEANLIB-209), and any consumer of `cleanlib-client`. The
9//! TS extension / Python MCP / JS+Go+Py SDKs mirror this table in their own
10//! languages, each with a contract test asserting the same wire→state map
11//! (anti-drift, sister of the silent-schema-drift discipline).
12//!
13//! ## Why this lives in `cleanlib-client`, not `cleanlib-core`
14//!
15//! `cleanlib-core` is `publish = false`; the customer-state taxonomy is a
16//! *presentation* concern consumed only by published render surfaces (the App
17//! emits the wire `source`; it never renders a customer state). Keying on the
18//! wire string (`Verdict.source: String`) — rather than `cleanlib-core`'s
19//! `VerdictSource` enum — keeps this crate publishable to crates.io and avoids
20//! a render-side dependency on the service-internal core crate.
21//!
22//! ## Design rules baked in (§0 of the spec — non-negotiable)
23//!
24//! 1. **Customer-language only** — wire/engine codenames never appear in
25//! `label()` / `copy()`.
26//! 2. **Soft severity tokens** (dark-theme-safe); brand cyan `#50C0E0` is
27//! reserved for CLEAN.
28//! 3. **Three derive tiers** (block / warn / clean) → exit codes (1 / 2 / 0).
29//!
30//! ## Derive-once + fail-closed
31//!
32//! Derive the state ONCE from the wire `source` ([`CustomerState::from_wire`]).
33//! The App applies severity precedence server-side when it picks the single
34//! `source`, so this map is 1:1 — no precedence is re-implemented here. An
35//! unrecognized / future `source` fails CLOSED to `NotYetAssessed` (warn,
36//! needs-review) — never silently Clean.
37
38/// The three derive tiers — drive exit codes (block=1, warn=2, clean=0) and
39/// the colour family, and map onto the universal ALLOW/WARN/DENY render bucket.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
41pub enum Tier {
42 /// exit 1 — DENY. Customer must not install / must remove.
43 Block,
44 /// exit 2 — WARN. Review / upgrade (fail-closed for unknown).
45 Warn,
46 /// exit 0 — ALLOW. No known issues.
47 Clean,
48}
49
50impl Tier {
51 /// Process exit code for the CLI gate (block=1, warn=2, clean=0).
52 pub fn exit_code(self) -> i32 {
53 match self {
54 Tier::Block => 1,
55 Tier::Warn => 2,
56 Tier::Clean => 0,
57 }
58 }
59}
60
61/// The 9 canonical customer-facing states, severity order (highest first) per
62/// the BD-ratified ordering in §1: Blocked-by-policy, Compromised, Malicious,
63/// Ransomware-linked, Actively-exploited, Vulnerable, Range-not-resolved,
64/// Not-yet-assessed, Clean. CLEANLIB-518(§3) added Range-not-resolved (8→9).
65#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
66pub enum CustomerState {
67 /// Customer's OWN policy rule blocked this package (operative gate).
68 BlockedByPolicy,
69 /// Tampered-with in a supply-chain attack (curated compromise list).
70 Compromised,
71 /// Known-malicious package (malware / typosquat).
72 Malicious,
73 /// Vulnerability tied to active ransomware campaigns.
74 RansomwareLinked,
75 /// Vulnerability being exploited in the wild (CISA KEV).
76 ActivelyExploited,
77 /// Has known CVEs (review + upgrade).
78 Vulnerable,
79 /// CLEANLIB-518 (§3): the requested semver range could not be resolved to a
80 /// concrete version (`source_state = RANGE_NOT_RESOLVED`). Distinct from
81 /// `NotYetAssessed` — the package is not un-assessed, the *range* is; the
82 /// customer must pin an exact version. Fail-closed needs-input (Warn).
83 RangeNotResolved,
84 /// No verdict on file yet — fail-closed needs-review.
85 NotYetAssessed,
86 /// No known issues found.
87 Clean,
88}
89
90impl CustomerState {
91 /// Derive the customer state from the wire `source` string (the values in
92 /// `crate::envelope::ALL_VERDICT_SOURCES`). Unknown / future variants fail
93 /// CLOSED to `NotYetAssessed` (warn) — never silently Clean.
94 ///
95 /// Two variants carry a note:
96 /// - `DM_THRESHOLD_BLOCK` → `BlockedByPolicy` (the customer's own rule).
97 /// The spec also routes a `DM_THRESHOLD_BLOCK` from the *curated*
98 /// supply-chain-compromise list (CLEANLIB-177) to `Compromised`, but that
99 /// distinction is carried by which rule fired, not by `source` alone, and
100 /// 177 is not yet shipped. Until 177 lands + the App tags curated-list
101 /// blocks, every `DM_THRESHOLD_BLOCK` is the customer's policy. See
102 /// [`from_block_origin`](CustomerState::from_block_origin).
103 /// - `VECTOR_VERDICT` is the legacy pre-176 full-eval source, preserved for
104 /// backward-compat. Post-176 the App projects findings into the specific
105 /// `CVE_FINDING*` variants, so a bare `VECTOR_VERDICT` reaching a render
106 /// surface is a legacy non-clean signal → `Vulnerable` (fail-safe).
107 pub fn from_wire(source: &str) -> CustomerState {
108 match source {
109 "ALLOWED_NO_FINDINGS" => CustomerState::Clean,
110 "CVE_FINDING" => CustomerState::Vulnerable,
111 "CVE_FINDING_ON_KEV" => CustomerState::ActivelyExploited,
112 "CVE_FINDING_ON_RANSOMWARE" => CustomerState::RansomwareLinked,
113 "MALICIOUS_TRIAGE" => CustomerState::Malicious,
114 "DM_THRESHOLD_BLOCK" => CustomerState::BlockedByPolicy,
115 "INSUFFICIENT_DATA" => CustomerState::NotYetAssessed,
116 // Legacy backward-compat — fail-safe to Vulnerable, never Clean.
117 "VECTOR_VERDICT" => CustomerState::Vulnerable,
118 // Unknown / future variant — fail CLOSED to needs-review.
119 _ => CustomerState::NotYetAssessed,
120 }
121 }
122
123 /// Same as [`from_wire`](CustomerState::from_wire) but lets the caller
124 /// resolve the `DM_THRESHOLD_BLOCK` ambiguity when it knows the block
125 /// origin. Use once CLEANLIB-177 ships and the App tags curated-list
126 /// blocks: `curated_compromise = true` routes `DM_THRESHOLD_BLOCK` to
127 /// `Compromised` instead of `BlockedByPolicy`.
128 pub fn from_block_origin(source: &str, curated_compromise: bool) -> CustomerState {
129 match (source, curated_compromise) {
130 ("DM_THRESHOLD_BLOCK", true) => CustomerState::Compromised,
131 _ => CustomerState::from_wire(source),
132 }
133 }
134
135 /// CLEANLIB-518 (§3): resolve the customer state taking the wire
136 /// `source_state` into account. A `RANGE_NOT_RESOLVED` source_state
137 /// (CLEANLIB-513 — App could not resolve the requested semver range) maps to
138 /// `RangeNotResolved` so the render banner reads "Range not resolved" and
139 /// agrees with the reasoning body, instead of collapsing to the generic
140 /// `NotYetAssessed` banner. All other source_states defer to
141 /// [`from_wire`](CustomerState::from_wire) on `source`.
142 pub fn from_wire_with_source_state(source: &str, source_state: Option<&str>) -> CustomerState {
143 match source_state {
144 // `RANGE_NOT_RESOLVED` (CLEANLIB-513/§3) is a source_state-only value
145 // — not a VerdictSource `from_wire` knows — so it keeps its explicit
146 // arm.
147 Some(s) if s.eq_ignore_ascii_case("RANGE_NOT_RESOLVED") => {
148 CustomerState::RangeNotResolved
149 }
150 // CLEANLIB-543: `source_state` is the envelope-v2 canonical status key
151 // (the v2 rename of `source`, carrying the refined origin —
152 // CVE_FINDING_ON_KEV / CVE_FINDING_ON_RANSOMWARE / MALICIOUS_TRIAGE /
153 // …). When present, derive from IT, not the coarse `source` (which the
154 // App may emit as a flat VECTOR_VERDICT on the v2 shape). This is what
155 // keeps `actively_exploited` / `ransomware_linked` from flattening to
156 // `vulnerable` when the origin rides in source_state. `from_wire`
157 // already maps the full VerdictSource corpus and fail-closes an
158 // unknown value to `not_yet_assessed`, so this stays fail-safe. Note
159 // the App emits `customer_state == from_wire(source_state)` server-side
160 // (§3), so preferring source_state is equivalent to trusting the
161 // pre-resolved customer_state.
162 Some(s) if !s.is_empty() => CustomerState::from_wire(s),
163 // v1 back-compat: no source_state → the origin rides in `source`.
164 _ => CustomerState::from_wire(source),
165 }
166 }
167
168 /// Stable machine string for `--output json` `state` field + structured
169 /// MCP/SDK fields. snake_case, customer-vocabulary, NOT the wire variant.
170 pub fn as_str(self) -> &'static str {
171 match self {
172 CustomerState::BlockedByPolicy => "blocked_by_policy",
173 CustomerState::Compromised => "compromised",
174 CustomerState::Malicious => "malicious",
175 CustomerState::RansomwareLinked => "ransomware_linked",
176 CustomerState::ActivelyExploited => "actively_exploited",
177 CustomerState::Vulnerable => "vulnerable",
178 CustomerState::RangeNotResolved => "range_not_resolved",
179 CustomerState::NotYetAssessed => "not_yet_assessed",
180 CustomerState::Clean => "clean",
181 }
182 }
183
184 /// Customer-facing display label (Title-case, no codenames). §2 of spec.
185 pub fn label(self) -> &'static str {
186 match self {
187 CustomerState::BlockedByPolicy => "Blocked by policy",
188 CustomerState::Compromised => "Compromised",
189 CustomerState::Malicious => "Malicious",
190 CustomerState::RansomwareLinked => "Ransomware-linked",
191 CustomerState::ActivelyExploited => "Actively exploited",
192 CustomerState::Vulnerable => "Vulnerable",
193 CustomerState::RangeNotResolved => "Range not resolved",
194 CustomerState::NotYetAssessed => "Not yet assessed",
195 CustomerState::Clean => "Clean",
196 }
197 }
198
199 /// One-line, action-first customer copy (no codenames, no raw tier words).
200 /// §2 of spec — verbatim.
201 pub fn copy(self) -> &'static str {
202 match self {
203 CustomerState::BlockedByPolicy => {
204 "Blocked by your organization's policy. Contact your security \
205 owner or request an exception."
206 }
207 CustomerState::Compromised => {
208 "This version was tampered with in a supply-chain attack. Do \
209 not install — use a known-good version."
210 }
211 CustomerState::Malicious => {
212 "Known-malicious package (malware / typosquat). Do not install."
213 }
214 CustomerState::RansomwareLinked => {
215 "Carries a vulnerability tied to active ransomware campaigns. \
216 Upgrade before shipping."
217 }
218 CustomerState::ActivelyExploited => {
219 "Has a vulnerability that's being exploited in the wild (on the \
220 U.S. CISA exploited list). Upgrade urgently."
221 }
222 CustomerState::Vulnerable => {
223 "Has known vulnerabilities (CVEs). Review and upgrade to a \
224 fixed version."
225 }
226 CustomerState::RangeNotResolved => {
227 "The requested version range couldn't be resolved to a concrete \
228 version. Pin an exact version and re-check."
229 }
230 CustomerState::NotYetAssessed => {
231 "No verdict on file yet — treat as needs-review until assessed."
232 }
233 CustomerState::Clean => "No known issues found.",
234 }
235 }
236
237 /// Derive tier (block / warn / clean). `NotYetAssessed` is fail-closed →
238 /// Warn. Per §1 "Tier" column.
239 pub fn tier(self) -> Tier {
240 match self {
241 CustomerState::BlockedByPolicy
242 | CustomerState::Compromised
243 | CustomerState::Malicious
244 | CustomerState::RansomwareLinked
245 | CustomerState::ActivelyExploited => Tier::Block,
246 CustomerState::Vulnerable
247 | CustomerState::RangeNotResolved
248 | CustomerState::NotYetAssessed => Tier::Warn,
249 CustomerState::Clean => Tier::Clean,
250 }
251 }
252
253 /// Process exit code for the CLI gate.
254 pub fn exit_code(self) -> i32 {
255 self.tier().exit_code()
256 }
257
258 /// CSS custom-property name for the soft severity token (extension + SDKs
259 /// consume these for theming). §1 "Soft colour token" column.
260 pub fn color_token(self) -> &'static str {
261 match self {
262 CustomerState::BlockedByPolicy => "--cl-policy",
263 CustomerState::Compromised => "--cl-compromised",
264 CustomerState::Malicious => "--cl-malicious",
265 CustomerState::RansomwareLinked => "--cl-ransomware",
266 CustomerState::ActivelyExploited => "--cl-exploited",
267 CustomerState::Vulnerable => "--cl-vulnerable",
268 // §5 reuse: range-not-resolved shares the needs-input token family;
269 // states differentiate by label, not a bespoke colour.
270 CustomerState::RangeNotResolved => "--cl-unknown",
271 CustomerState::NotYetAssessed => "--cl-unknown",
272 CustomerState::Clean => "--cl-clean",
273 }
274 }
275
276 /// Soft severity hex (draft-ratified working palette, §5). Brand cyan for
277 /// Clean; distinct mauve for policy so "your rule" reads apart from "our
278 /// finding" reds.
279 pub fn color_hex(self) -> &'static str {
280 match self {
281 CustomerState::BlockedByPolicy => "#C77DBB",
282 CustomerState::Compromised => "#D16D6A",
283 CustomerState::Malicious => "#D9534F",
284 CustomerState::RansomwareLinked => "#DB7B57",
285 CustomerState::ActivelyExploited => "#E0934A",
286 CustomerState::Vulnerable => "#D9A441",
287 CustomerState::RangeNotResolved => "#7C8696",
288 CustomerState::NotYetAssessed => "#7C8696",
289 CustomerState::Clean => "#50C0E0",
290 }
291 }
292
293 /// Text-surface emoji for MCP `human_message` + CLI text output. §1
294 /// "MCP/CLI emoji" column. One concept per tier; states differentiate by
295 /// label + colour, not a bespoke glyph family (§5 — reuse, don't cut).
296 pub fn emoji(self) -> &'static str {
297 match self {
298 CustomerState::BlockedByPolicy => "🛑",
299 CustomerState::Compromised => "⛔",
300 CustomerState::Malicious => "⛔",
301 CustomerState::RansomwareLinked => "🔴",
302 CustomerState::ActivelyExploited => "🔴",
303 CustomerState::Vulnerable => "⚠️",
304 CustomerState::RangeNotResolved => "❔",
305 CustomerState::NotYetAssessed => "❔",
306 CustomerState::Clean => "✅",
307 }
308 }
309
310 /// All 9 states, severity order (highest first). For exhaustive contract
311 /// tests + doc generation. CLEANLIB-518(§3) added RangeNotResolved (8→9).
312 pub fn all() -> [CustomerState; 9] {
313 [
314 CustomerState::BlockedByPolicy,
315 CustomerState::Compromised,
316 CustomerState::Malicious,
317 CustomerState::RansomwareLinked,
318 CustomerState::ActivelyExploited,
319 CustomerState::Vulnerable,
320 CustomerState::RangeNotResolved,
321 CustomerState::NotYetAssessed,
322 CustomerState::Clean,
323 ]
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use crate::envelope::ALL_VERDICT_SOURCES;
331
332 // ── The wire→state mappings (the cross-surface contract) ────────────────
333 #[test]
334 fn wire_to_state_mapping_is_canonical() {
335 use CustomerState::*;
336 assert_eq!(CustomerState::from_wire("ALLOWED_NO_FINDINGS"), Clean);
337 assert_eq!(CustomerState::from_wire("CVE_FINDING"), Vulnerable);
338 assert_eq!(CustomerState::from_wire("CVE_FINDING_ON_KEV"), ActivelyExploited);
339 assert_eq!(CustomerState::from_wire("CVE_FINDING_ON_RANSOMWARE"), RansomwareLinked);
340 assert_eq!(CustomerState::from_wire("MALICIOUS_TRIAGE"), Malicious);
341 assert_eq!(CustomerState::from_wire("DM_THRESHOLD_BLOCK"), BlockedByPolicy);
342 assert_eq!(CustomerState::from_wire("INSUFFICIENT_DATA"), NotYetAssessed);
343 assert_eq!(CustomerState::from_wire("VECTOR_VERDICT"), Vulnerable);
344 }
345
346 #[test]
347 fn every_known_wire_source_maps_without_falling_through() {
348 // Anti-drift: every value the envelope declares must have an explicit
349 // mapping (not silently hit the fail-closed `_` arm). If 176/177 adds
350 // a wire source, this fails until the map is extended.
351 for &src in ALL_VERDICT_SOURCES {
352 let state = CustomerState::from_wire(src);
353 // ALLOWED_NO_FINDINGS is the only known source that may be Clean.
354 if src != "ALLOWED_NO_FINDINGS" {
355 assert_ne!(
356 state,
357 CustomerState::Clean,
358 "known non-clean source {src} mapped to Clean"
359 );
360 }
361 }
362 }
363
364 #[test]
365 fn cleanlib_518_range_not_resolved_source_state_maps_distinctly() {
366 // §3: a RANGE_NOT_RESOLVED source_state yields the distinct
367 // RangeNotResolved state (not the generic NotYetAssessed), so the CLI
368 // banner label agrees with the reasoning body. Case-insensitive.
369 assert_eq!(
370 CustomerState::from_wire_with_source_state("INSUFFICIENT_DATA", Some("RANGE_NOT_RESOLVED")),
371 CustomerState::RangeNotResolved
372 );
373 assert_eq!(
374 CustomerState::from_wire_with_source_state("INSUFFICIENT_DATA", Some("range_not_resolved")),
375 CustomerState::RangeNotResolved
376 );
377 // Banner↔body coherence: the label is no longer "Not yet assessed".
378 assert_eq!(CustomerState::RangeNotResolved.label(), "Range not resolved");
379 assert_ne!(
380 CustomerState::RangeNotResolved.label(),
381 CustomerState::NotYetAssessed.label()
382 );
383 // Fail-closed: an unresolved range is a WARN, never an ALLOW.
384 assert_eq!(CustomerState::RangeNotResolved.exit_code(), 2);
385 // No source_state (or a different one) still defers to `source`.
386 assert_eq!(
387 CustomerState::from_wire_with_source_state("CVE_FINDING", None),
388 CustomerState::Vulnerable
389 );
390 assert_eq!(
391 CustomerState::from_wire_with_source_state("CVE_FINDING", Some("CVE_FINDING")),
392 CustomerState::Vulnerable
393 );
394 }
395
396 #[test]
397 fn unknown_source_fails_closed_to_needs_review() {
398 assert_eq!(CustomerState::from_wire("SOME_FUTURE_VARIANT"), CustomerState::NotYetAssessed);
399 assert_eq!(CustomerState::from_wire(""), CustomerState::NotYetAssessed);
400 assert_ne!(CustomerState::from_wire("SOME_FUTURE_VARIANT"), CustomerState::Clean);
401 }
402
403 #[test]
404 fn curated_compromise_routes_dm_block_to_compromised() {
405 assert_eq!(
406 CustomerState::from_block_origin("DM_THRESHOLD_BLOCK", true),
407 CustomerState::Compromised
408 );
409 assert_eq!(
410 CustomerState::from_block_origin("DM_THRESHOLD_BLOCK", false),
411 CustomerState::BlockedByPolicy
412 );
413 assert_eq!(
414 CustomerState::from_block_origin("CVE_FINDING", true),
415 CustomerState::Vulnerable
416 );
417 }
418
419 #[test]
420 fn tier_and_exit_codes() {
421 assert_eq!(CustomerState::Clean.exit_code(), 0);
422 assert_eq!(CustomerState::Vulnerable.exit_code(), 2);
423 assert_eq!(CustomerState::NotYetAssessed.exit_code(), 2); // fail-closed
424 for s in [
425 CustomerState::ActivelyExploited,
426 CustomerState::RansomwareLinked,
427 CustomerState::Malicious,
428 CustomerState::Compromised,
429 CustomerState::BlockedByPolicy,
430 ] {
431 assert_eq!(s.exit_code(), 1, "{s:?} must be block-tier");
432 }
433 }
434
435 #[test]
436 fn no_internal_codenames_in_customer_strings() {
437 let banned = [
438 "Vector", "VECTOR", "Tricorder", "DmThreshold", "DM_THRESHOLD",
439 "CveFinding", "CVE_FINDING", "MaliciousTriage", "MALICIOUS_TRIAGE",
440 "InsufficientData", "INSUFFICIENT_DATA", "vuln_xref", "mock", "fixture",
441 ];
442 for s in CustomerState::all() {
443 for text in [s.label(), s.copy()] {
444 for b in banned {
445 assert!(
446 !text.contains(b),
447 "codename {b:?} leaked into customer string {text:?} for {s:?}"
448 );
449 }
450 }
451 }
452 }
453
454 #[test]
455 fn no_raw_tier_words_in_customer_copy() {
456 for s in CustomerState::all() {
457 for b in ["DENY", "WARN", "ALLOW"] {
458 assert!(!s.copy().contains(b), "raw tier word {b} in copy for {s:?}");
459 assert!(!s.label().contains(b), "raw tier word {b} in label for {s:?}");
460 }
461 }
462 }
463
464 #[test]
465 fn every_state_has_complete_token_set() {
466 for s in CustomerState::all() {
467 assert!(!s.as_str().is_empty());
468 assert!(!s.label().is_empty());
469 assert!(!s.copy().is_empty());
470 assert!(s.color_token().starts_with("--cl-"));
471 assert!(s.color_hex().starts_with('#') && s.color_hex().len() == 7);
472 assert!(!s.emoji().is_empty());
473 }
474 }
475
476 #[test]
477 fn clean_uses_brand_cyan_others_do_not() {
478 assert_eq!(CustomerState::Clean.color_hex(), "#50C0E0");
479 for s in CustomerState::all() {
480 if s != CustomerState::Clean {
481 assert_ne!(s.color_hex(), "#50C0E0", "{s:?} must not reuse brand cyan");
482 }
483 }
484 }
485
486 #[test]
487 fn machine_strings_are_unique() {
488 let mut seen = std::collections::HashSet::new();
489 for s in CustomerState::all() {
490 assert!(seen.insert(s.as_str()), "duplicate state string {}", s.as_str());
491 }
492 }
493}