Skip to main content

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// CLEANLIB-657 (CX-8): #[non_exhaustive] forces external consumers to include a
66// wildcard arm, so a future state can never be silently mis-handled — and, with
67// NotYetAssessed/RangeNotResolved as their own variants, a caller can NEVER
68// accidentally treat 'not assessed' as allowed. Uncertainty lives in the type.
69#[non_exhaustive]
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
71pub enum CustomerState {
72    /// Customer's OWN policy rule blocked this package (operative gate).
73    BlockedByPolicy,
74    /// Tampered-with in a supply-chain attack (curated compromise list).
75    Compromised,
76    /// Known-malicious package (malware / typosquat).
77    Malicious,
78    /// Vulnerability tied to active ransomware campaigns.
79    RansomwareLinked,
80    /// Vulnerability being exploited in the wild (CISA KEV).
81    ActivelyExploited,
82    /// Has known CVEs (review + upgrade).
83    Vulnerable,
84    /// CLEANLIB-518 (§3): the requested semver range could not be resolved to a
85    /// concrete version (`source_state = RANGE_NOT_RESOLVED`). Distinct from
86    /// `NotYetAssessed` — the package is not un-assessed, the *range* is; the
87    /// customer must pin an exact version. Fail-closed needs-input (Warn).
88    RangeNotResolved,
89    /// No verdict on file yet — fail-closed needs-review.
90    NotYetAssessed,
91    /// No known issues found.
92    Clean,
93}
94
95impl CustomerState {
96    /// Derive the customer state from the wire `source` string (the values in
97    /// `crate::envelope::ALL_VERDICT_SOURCES`). Unknown / future variants fail
98    /// CLOSED to `NotYetAssessed` (warn) — never silently Clean.
99    ///
100    /// Two variants carry a note:
101    /// - `DM_THRESHOLD_BLOCK` → `BlockedByPolicy` (the customer's own rule).
102    ///   The spec also routes a `DM_THRESHOLD_BLOCK` from the *curated*
103    ///   supply-chain-compromise list (CLEANLIB-177) to `Compromised`, but that
104    ///   distinction is carried by which rule fired, not by `source` alone, and
105    ///   177 is not yet shipped. Until 177 lands + the App tags curated-list
106    ///   blocks, every `DM_THRESHOLD_BLOCK` is the customer's policy. See
107    ///   [`from_block_origin`](CustomerState::from_block_origin).
108    /// - `VECTOR_VERDICT` is the legacy pre-176 full-eval source, preserved for
109    ///   backward-compat. Post-176 the App projects findings into the specific
110    ///   `CVE_FINDING*` variants, so a bare `VECTOR_VERDICT` reaching a render
111    ///   surface is a legacy non-clean signal → `Vulnerable` (fail-safe).
112    pub fn from_wire(source: &str) -> CustomerState {
113        match source {
114            "ALLOWED_NO_FINDINGS" => CustomerState::Clean,
115            "CVE_FINDING" => CustomerState::Vulnerable,
116            "CVE_FINDING_ON_KEV" => CustomerState::ActivelyExploited,
117            "CVE_FINDING_ON_RANSOMWARE" => CustomerState::RansomwareLinked,
118            "MALICIOUS_TRIAGE" => CustomerState::Malicious,
119            "DM_THRESHOLD_BLOCK" => CustomerState::BlockedByPolicy,
120            "INSUFFICIENT_DATA" => CustomerState::NotYetAssessed,
121            // Legacy backward-compat — fail-safe to Vulnerable, never Clean.
122            "VECTOR_VERDICT" => CustomerState::Vulnerable,
123            // Unknown / future variant — fail CLOSED to needs-review.
124            _ => CustomerState::NotYetAssessed,
125        }
126    }
127
128    /// Same as [`from_wire`](CustomerState::from_wire) but lets the caller
129    /// resolve the `DM_THRESHOLD_BLOCK` ambiguity when it knows the block
130    /// origin. Use once CLEANLIB-177 ships and the App tags curated-list
131    /// blocks: `curated_compromise = true` routes `DM_THRESHOLD_BLOCK` to
132    /// `Compromised` instead of `BlockedByPolicy`.
133    pub fn from_block_origin(source: &str, curated_compromise: bool) -> CustomerState {
134        match (source, curated_compromise) {
135            ("DM_THRESHOLD_BLOCK", true) => CustomerState::Compromised,
136            _ => CustomerState::from_wire(source),
137        }
138    }
139
140    /// CLEANLIB-518 (§3): resolve the customer state taking the wire
141    /// `source_state` into account. A `RANGE_NOT_RESOLVED` source_state
142    /// (CLEANLIB-513 — App could not resolve the requested semver range) maps to
143    /// `RangeNotResolved` so the render banner reads "Range not resolved" and
144    /// agrees with the reasoning body, instead of collapsing to the generic
145    /// `NotYetAssessed` banner. All other source_states defer to
146    /// [`from_wire`](CustomerState::from_wire) on `source`.
147    pub fn from_wire_with_source_state(source: &str, source_state: Option<&str>) -> CustomerState {
148        match source_state {
149            // `RANGE_NOT_RESOLVED` (CLEANLIB-513/§3) is a source_state-only value
150            // — not a VerdictSource `from_wire` knows — so it keeps its explicit
151            // arm.
152            Some(s) if s.eq_ignore_ascii_case("RANGE_NOT_RESOLVED") => {
153                CustomerState::RangeNotResolved
154            }
155            // CLEANLIB-543: `source_state` is the envelope-v2 canonical status key
156            // (the v2 rename of `source`, carrying the refined origin —
157            // CVE_FINDING_ON_KEV / CVE_FINDING_ON_RANSOMWARE / MALICIOUS_TRIAGE /
158            // …). When present, derive from IT, not the coarse `source` (which the
159            // App may emit as a flat VECTOR_VERDICT on the v2 shape). This is what
160            // keeps `actively_exploited` / `ransomware_linked` from flattening to
161            // `vulnerable` when the origin rides in source_state. `from_wire`
162            // already maps the full VerdictSource corpus and fail-closes an
163            // unknown value to `not_yet_assessed`, so this stays fail-safe. Note
164            // the App emits `customer_state == from_wire(source_state)` server-side
165            // (§3), so preferring source_state is equivalent to trusting the
166            // pre-resolved customer_state.
167            Some(s) if !s.is_empty() => CustomerState::from_wire(s),
168            // v1 back-compat: no source_state → the origin rides in `source`.
169            _ => CustomerState::from_wire(source),
170        }
171    }
172
173    /// CLEANLIB-855: same as [`from_wire_with_source_state`], PLUS resolves
174    /// the `DM_THRESHOLD_BLOCK` → `Compromised` distinction via
175    /// [`from_block_origin`] when `matched_rule_id` names a curated
176    /// supply-chain-compromise-bridge rule (see
177    /// [`is_curated_supply_chain_compromise`]).
178    ///
179    /// This is the render call sites' entry point once the App threads
180    /// `matched_rule_id` onto the wire (CLEANLIB-855) — before that field
181    /// existed, [`from_wire_with_source_state`] was the only option and
182    /// `Compromised` was permanently unreachable from any real verdict
183    /// (only unit tests ever constructed it directly).
184    pub fn from_wire_with_source_state_and_origin(
185        source: &str,
186        source_state: Option<&str>,
187        matched_rule_id: Option<&str>,
188    ) -> CustomerState {
189        if is_curated_supply_chain_compromise(matched_rule_id) {
190            // `from_block_origin` only special-cases `DM_THRESHOLD_BLOCK` —
191            // resolve against whichever of source_state/source actually
192            // carries that token (same precedence as
193            // `from_wire_with_source_state`), THEN apply the curated-origin
194            // override on top.
195            let effective_source = match source_state {
196                Some(s) if !s.is_empty() => s,
197                _ => source,
198            };
199            return CustomerState::from_block_origin(effective_source, true);
200        }
201        CustomerState::from_wire_with_source_state(source, source_state)
202    }
203
204    /// Stable machine string for `--output json` `state` field + structured
205    /// MCP/SDK fields. snake_case, customer-vocabulary, NOT the wire variant.
206    pub fn as_str(self) -> &'static str {
207        match self {
208            CustomerState::BlockedByPolicy => "blocked_by_policy",
209            CustomerState::Compromised => "compromised",
210            CustomerState::Malicious => "malicious",
211            CustomerState::RansomwareLinked => "ransomware_linked",
212            CustomerState::ActivelyExploited => "actively_exploited",
213            CustomerState::Vulnerable => "vulnerable",
214            CustomerState::RangeNotResolved => "range_not_resolved",
215            CustomerState::NotYetAssessed => "not_yet_assessed",
216            CustomerState::Clean => "clean",
217        }
218    }
219
220    /// Customer-facing display label (Title-case, no codenames). §2 of spec.
221    pub fn label(self) -> &'static str {
222        match self {
223            CustomerState::BlockedByPolicy => "Blocked by policy",
224            CustomerState::Compromised => "Compromised",
225            CustomerState::Malicious => "Malicious",
226            CustomerState::RansomwareLinked => "Ransomware-linked",
227            CustomerState::ActivelyExploited => "Actively exploited",
228            CustomerState::Vulnerable => "Vulnerable",
229            CustomerState::RangeNotResolved => "Range not resolved",
230            CustomerState::NotYetAssessed => "Not yet assessed",
231            CustomerState::Clean => "Clean",
232        }
233    }
234
235    /// One-line, action-first customer copy (no codenames, no raw tier words).
236    /// §2 of spec — verbatim.
237    pub fn copy(self) -> &'static str {
238        match self {
239            CustomerState::BlockedByPolicy => {
240                "Blocked by your organization's policy. Contact your security \
241                 owner or request an exception."
242            }
243            CustomerState::Compromised => {
244                "This version was tampered with in a supply-chain attack. Do \
245                 not install — use a known-good version."
246            }
247            CustomerState::Malicious => {
248                "Known-malicious package (malware / typosquat). Do not install."
249            }
250            CustomerState::RansomwareLinked => {
251                "Carries a vulnerability tied to active ransomware campaigns. \
252                 Upgrade before shipping."
253            }
254            CustomerState::ActivelyExploited => {
255                "Has a vulnerability that's being exploited in the wild (on the \
256                 U.S. CISA exploited list). Upgrade urgently."
257            }
258            CustomerState::Vulnerable => {
259                "Has known vulnerabilities (CVEs). Review and upgrade to a \
260                 fixed version."
261            }
262            CustomerState::RangeNotResolved => {
263                "The requested version range couldn't be resolved to a concrete \
264                 version. Pin an exact version and re-check."
265            }
266            CustomerState::NotYetAssessed => {
267                "No verdict on file yet — treat as needs-review until assessed."
268            }
269            CustomerState::Clean => "No known issues found.",
270        }
271    }
272
273    /// Derive tier (block / warn / clean). `NotYetAssessed` is fail-closed →
274    /// Warn. Per §1 "Tier" column.
275    pub fn tier(self) -> Tier {
276        match self {
277            CustomerState::BlockedByPolicy
278            | CustomerState::Compromised
279            | CustomerState::Malicious
280            | CustomerState::RansomwareLinked
281            | CustomerState::ActivelyExploited => Tier::Block,
282            CustomerState::Vulnerable
283            | CustomerState::RangeNotResolved
284            | CustomerState::NotYetAssessed => Tier::Warn,
285            CustomerState::Clean => Tier::Clean,
286        }
287    }
288
289    /// Process exit code for the CLI gate.
290    pub fn exit_code(self) -> i32 {
291        self.tier().exit_code()
292    }
293
294    /// The "needs-input" family: warn-tier states that are NOT a security
295    /// finding but a gap the customer must resolve themselves — either no
296    /// verdict is on file yet (`NotYetAssessed`) or the requested version range
297    /// could not be resolved to a concrete version (`RangeNotResolved`,
298    /// CLEANLIB-518). Both share the `--cl-unknown` colour token + `❔` glyph
299    /// (§5). Render surfaces that distinguish "we found a problem" from "we
300    /// need input" — e.g. the LSP diagnostic severity, which shows this family
301    /// as INFORMATION rather than a WARNING finding — key on this predicate, so
302    /// a future needs-input state is threaded in ONE canonical place rather than
303    /// re-listed per surface (sister of the silent-drift discipline).
304    pub fn is_needs_input(self) -> bool {
305        matches!(
306            self,
307            CustomerState::NotYetAssessed | CustomerState::RangeNotResolved
308        )
309    }
310
311    /// CSS custom-property name for the soft severity token (extension + SDKs
312    /// consume these for theming). §1 "Soft colour token" column.
313    pub fn color_token(self) -> &'static str {
314        match self {
315            CustomerState::BlockedByPolicy => "--cl-policy",
316            CustomerState::Compromised => "--cl-compromised",
317            CustomerState::Malicious => "--cl-malicious",
318            CustomerState::RansomwareLinked => "--cl-ransomware",
319            CustomerState::ActivelyExploited => "--cl-exploited",
320            CustomerState::Vulnerable => "--cl-vulnerable",
321            // §5 reuse: range-not-resolved shares the needs-input token family;
322            // states differentiate by label, not a bespoke colour.
323            CustomerState::RangeNotResolved => "--cl-unknown",
324            CustomerState::NotYetAssessed => "--cl-unknown",
325            CustomerState::Clean => "--cl-clean",
326        }
327    }
328
329    /// Soft severity hex (draft-ratified working palette, §5). Brand cyan for
330    /// Clean; distinct mauve for policy so "your rule" reads apart from "our
331    /// finding" reds.
332    pub fn color_hex(self) -> &'static str {
333        match self {
334            CustomerState::BlockedByPolicy => "#C77DBB",
335            CustomerState::Compromised => "#D16D6A",
336            CustomerState::Malicious => "#D9534F",
337            CustomerState::RansomwareLinked => "#DB7B57",
338            CustomerState::ActivelyExploited => "#E0934A",
339            CustomerState::Vulnerable => "#D9A441",
340            CustomerState::RangeNotResolved => "#7C8696",
341            CustomerState::NotYetAssessed => "#7C8696",
342            CustomerState::Clean => "#50C0E0",
343        }
344    }
345
346    /// Text-surface emoji for MCP `human_message` + CLI text output. §1
347    /// "MCP/CLI emoji" column. One concept per tier; states differentiate by
348    /// label + colour, not a bespoke glyph family (§5 — reuse, don't cut).
349    pub fn emoji(self) -> &'static str {
350        match self {
351            CustomerState::BlockedByPolicy => "🛑",
352            CustomerState::Compromised => "⛔",
353            CustomerState::Malicious => "⛔",
354            CustomerState::RansomwareLinked => "🔴",
355            CustomerState::ActivelyExploited => "🔴",
356            CustomerState::Vulnerable => "⚠️",
357            CustomerState::RangeNotResolved => "❔",
358            CustomerState::NotYetAssessed => "❔",
359            CustomerState::Clean => "✅",
360        }
361    }
362
363    /// All 9 states, severity order (highest first). For exhaustive contract
364    /// tests + doc generation. CLEANLIB-518(§3) added RangeNotResolved (8→9).
365    pub fn all() -> [CustomerState; 9] {
366        [
367            CustomerState::BlockedByPolicy,
368            CustomerState::Compromised,
369            CustomerState::Malicious,
370            CustomerState::RansomwareLinked,
371            CustomerState::ActivelyExploited,
372            CustomerState::Vulnerable,
373            CustomerState::RangeNotResolved,
374            CustomerState::NotYetAssessed,
375            CustomerState::Clean,
376        ]
377    }
378}
379
380/// CLEANLIB-855: `true` iff `matched_rule_id` names a curated
381/// supply-chain-compromise-bridge policy rule (the App-side prefix
382/// convention for the CLEANLIB-177 curated compromise list), rather than an
383/// ordinary customer policy rule. This is the one place that prefix
384/// convention is checked on the render side — callers should go through this
385/// function (or [`CustomerState::from_wire_with_source_state_and_origin`]
386/// directly) rather than re-implementing the `starts_with` check, so the
387/// convention only needs to change in one place if it ever does.
388pub fn is_curated_supply_chain_compromise(matched_rule_id: Option<&str>) -> bool {
389    matched_rule_id
390        .map(|id| id.starts_with("supply-chain-compromise-bridge-"))
391        .unwrap_or(false)
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::envelope::ALL_VERDICT_SOURCES;
398
399    // ── The wire→state mappings (the cross-surface contract) ────────────────
400    #[test]
401    fn wire_to_state_mapping_is_canonical() {
402        use CustomerState::*;
403        assert_eq!(CustomerState::from_wire("ALLOWED_NO_FINDINGS"), Clean);
404        assert_eq!(CustomerState::from_wire("CVE_FINDING"), Vulnerable);
405        assert_eq!(CustomerState::from_wire("CVE_FINDING_ON_KEV"), ActivelyExploited);
406        assert_eq!(CustomerState::from_wire("CVE_FINDING_ON_RANSOMWARE"), RansomwareLinked);
407        assert_eq!(CustomerState::from_wire("MALICIOUS_TRIAGE"), Malicious);
408        assert_eq!(CustomerState::from_wire("DM_THRESHOLD_BLOCK"), BlockedByPolicy);
409        assert_eq!(CustomerState::from_wire("INSUFFICIENT_DATA"), NotYetAssessed);
410        assert_eq!(CustomerState::from_wire("VECTOR_VERDICT"), Vulnerable);
411    }
412
413    #[test]
414    fn every_known_wire_source_maps_without_falling_through() {
415        // Anti-drift: every value the envelope declares must have an explicit
416        // mapping (not silently hit the fail-closed `_` arm). If 176/177 adds
417        // a wire source, this fails until the map is extended.
418        for &src in ALL_VERDICT_SOURCES {
419            let state = CustomerState::from_wire(src);
420            // ALLOWED_NO_FINDINGS is the only known source that may be Clean.
421            if src != "ALLOWED_NO_FINDINGS" {
422                assert_ne!(
423                    state,
424                    CustomerState::Clean,
425                    "known non-clean source {src} mapped to Clean"
426                );
427            }
428        }
429    }
430
431    #[test]
432    fn cleanlib_518_range_not_resolved_source_state_maps_distinctly() {
433        // §3: a RANGE_NOT_RESOLVED source_state yields the distinct
434        // RangeNotResolved state (not the generic NotYetAssessed), so the CLI
435        // banner label agrees with the reasoning body. Case-insensitive.
436        assert_eq!(
437            CustomerState::from_wire_with_source_state("INSUFFICIENT_DATA", Some("RANGE_NOT_RESOLVED")),
438            CustomerState::RangeNotResolved
439        );
440        assert_eq!(
441            CustomerState::from_wire_with_source_state("INSUFFICIENT_DATA", Some("range_not_resolved")),
442            CustomerState::RangeNotResolved
443        );
444        // Banner↔body coherence: the label is no longer "Not yet assessed".
445        assert_eq!(CustomerState::RangeNotResolved.label(), "Range not resolved");
446        assert_ne!(
447            CustomerState::RangeNotResolved.label(),
448            CustomerState::NotYetAssessed.label()
449        );
450        // Fail-closed: an unresolved range is a WARN, never an ALLOW.
451        assert_eq!(CustomerState::RangeNotResolved.exit_code(), 2);
452        // No source_state (or a different one) still defers to `source`.
453        assert_eq!(
454            CustomerState::from_wire_with_source_state("CVE_FINDING", None),
455            CustomerState::Vulnerable
456        );
457        assert_eq!(
458            CustomerState::from_wire_with_source_state("CVE_FINDING", Some("CVE_FINDING")),
459            CustomerState::Vulnerable
460        );
461    }
462
463    #[test]
464    fn unknown_source_fails_closed_to_needs_review() {
465        assert_eq!(CustomerState::from_wire("SOME_FUTURE_VARIANT"), CustomerState::NotYetAssessed);
466        assert_eq!(CustomerState::from_wire(""), CustomerState::NotYetAssessed);
467        assert_ne!(CustomerState::from_wire("SOME_FUTURE_VARIANT"), CustomerState::Clean);
468    }
469
470    #[test]
471    fn curated_compromise_routes_dm_block_to_compromised() {
472        assert_eq!(
473            CustomerState::from_block_origin("DM_THRESHOLD_BLOCK", true),
474            CustomerState::Compromised
475        );
476        assert_eq!(
477            CustomerState::from_block_origin("DM_THRESHOLD_BLOCK", false),
478            CustomerState::BlockedByPolicy
479        );
480        assert_eq!(
481            CustomerState::from_block_origin("CVE_FINDING", true),
482            CustomerState::Vulnerable
483        );
484    }
485
486    #[test]
487    fn tier_and_exit_codes() {
488        assert_eq!(CustomerState::Clean.exit_code(), 0);
489        assert_eq!(CustomerState::Vulnerable.exit_code(), 2);
490        assert_eq!(CustomerState::NotYetAssessed.exit_code(), 2); // fail-closed
491        for s in [
492            CustomerState::ActivelyExploited,
493            CustomerState::RansomwareLinked,
494            CustomerState::Malicious,
495            CustomerState::Compromised,
496            CustomerState::BlockedByPolicy,
497        ] {
498            assert_eq!(s.exit_code(), 1, "{s:?} must be block-tier");
499        }
500    }
501
502    #[test]
503    fn no_internal_codenames_in_customer_strings() {
504        let banned = [
505            "Vector", "VECTOR", "Tricorder", "DmThreshold", "DM_THRESHOLD",
506            "CveFinding", "CVE_FINDING", "MaliciousTriage", "MALICIOUS_TRIAGE",
507            "InsufficientData", "INSUFFICIENT_DATA", "vuln_xref", "mock", "fixture",
508        ];
509        for s in CustomerState::all() {
510            for text in [s.label(), s.copy()] {
511                for b in banned {
512                    assert!(
513                        !text.contains(b),
514                        "codename {b:?} leaked into customer string {text:?} for {s:?}"
515                    );
516                }
517            }
518        }
519    }
520
521    #[test]
522    fn no_raw_tier_words_in_customer_copy() {
523        for s in CustomerState::all() {
524            for b in ["DENY", "WARN", "ALLOW"] {
525                assert!(!s.copy().contains(b), "raw tier word {b} in copy for {s:?}");
526                assert!(!s.label().contains(b), "raw tier word {b} in label for {s:?}");
527            }
528        }
529    }
530
531    #[test]
532    fn every_state_has_complete_token_set() {
533        for s in CustomerState::all() {
534            assert!(!s.as_str().is_empty());
535            assert!(!s.label().is_empty());
536            assert!(!s.copy().is_empty());
537            assert!(s.color_token().starts_with("--cl-"));
538            assert!(s.color_hex().starts_with('#') && s.color_hex().len() == 7);
539            assert!(!s.emoji().is_empty());
540        }
541    }
542
543    #[test]
544    fn clean_uses_brand_cyan_others_do_not() {
545        assert_eq!(CustomerState::Clean.color_hex(), "#50C0E0");
546        for s in CustomerState::all() {
547            if s != CustomerState::Clean {
548                assert_ne!(s.color_hex(), "#50C0E0", "{s:?} must not reuse brand cyan");
549            }
550        }
551    }
552
553    #[test]
554    fn needs_input_family_is_exactly_not_yet_assessed_and_range_not_resolved() {
555        // The needs-input predicate is the single source of truth for "not a
556        // finding, needs customer input". It must cover exactly the two states
557        // that share the `--cl-unknown` token + `❔` glyph — no more, no less.
558        // A future state added to this family updates this test + the predicate
559        // together; a finding state accidentally landing here fails loudly.
560        for s in CustomerState::all() {
561            let expected = matches!(
562                s,
563                CustomerState::NotYetAssessed | CustomerState::RangeNotResolved
564            );
565            assert_eq!(s.is_needs_input(), expected, "{s:?}");
566            if s.is_needs_input() {
567                // Needs-input states are warn-tier (never block/clean) and carry
568                // the shared unknown visual family — the invariant the LSP and
569                // other surfaces rely on.
570                assert_eq!(s.tier(), Tier::Warn, "{s:?} needs-input must be warn-tier");
571                assert_eq!(s.color_token(), "--cl-unknown", "{s:?}");
572                assert_eq!(s.emoji(), "❔", "{s:?}");
573            }
574        }
575    }
576
577    #[test]
578    fn machine_strings_are_unique() {
579        let mut seen = std::collections::HashSet::new();
580        for s in CustomerState::all() {
581            assert!(seen.insert(s.as_str()), "duplicate state string {}", s.as_str());
582        }
583    }
584    #[test]
585    fn cleanlib_657_cx8_only_clean_is_allowed_tier() {
586        // CX-8 type invariant: `Clean` is the ONLY state at the allowed (Clean)
587        // tier. Every other state — crucially NotYetAssessed and RangeNotResolved
588        // (the "we did not / could not assess" states) — is Warn or Block tier,
589        // so a caller can NEVER accidentally treat "not assessed" as allowed.
590        // [Absence≠safe] enforced by the type, not by convention.
591        use CustomerState::*;
592        let all = [
593            BlockedByPolicy, Compromised, Malicious, RansomwareLinked,
594            ActivelyExploited, Vulnerable, RangeNotResolved, NotYetAssessed, Clean,
595        ];
596        for s in all {
597            if matches!(s, Clean) {
598                assert_eq!(s.tier(), Tier::Clean, "Clean must be allowed-tier");
599            } else {
600                assert_ne!(s.tier(), Tier::Clean, "{s:?} must NEVER be allowed (Clean) tier");
601            }
602        }
603    }
604
605    // ─── CLEANLIB-855: from_block_origin finally has a real call path ────────
606
607    #[test]
608    fn cleanlib_855_is_curated_supply_chain_compromise_matches_the_real_bridge_prefix() {
609        assert!(is_curated_supply_chain_compromise(Some(
610            "supply-chain-compromise-bridge-event-stream-2018-mh6f-8j2x-4483"
611        )));
612        assert!(is_curated_supply_chain_compromise(Some(
613            "supply-chain-compromise-bridge-ctx-2022-67r3-h899-9w95-compromised"
614        )));
615        // An ordinary customer policy rule is NOT a curated compromise match,
616        // even if it happens to deny the same package.
617        assert!(!is_curated_supply_chain_compromise(Some("rule_cors_deny_old")));
618        assert!(!is_curated_supply_chain_compromise(None));
619        assert!(!is_curated_supply_chain_compromise(Some("")));
620    }
621
622    #[test]
623    fn cleanlib_855_from_wire_with_source_state_and_origin_renders_compromised() {
624        // The exact counterexample the ticket asks for: a curated bridge rule
625        // match must render Compromised, not the generic BlockedByPolicy.
626        let state = CustomerState::from_wire_with_source_state_and_origin(
627            "DM_THRESHOLD_BLOCK",
628            None,
629            Some("supply-chain-compromise-bridge-event-stream-2018-mh6f-8j2x-4483"),
630        );
631        assert_eq!(state, CustomerState::Compromised);
632        assert_eq!(state.label(), "Compromised");
633    }
634
635    #[test]
636    fn cleanlib_855_from_wire_with_source_state_and_origin_ordinary_policy_denies_unaffected() {
637        // Counterexample's other pole: an ordinary customer DENY rule must
638        // still render BlockedByPolicy, not spuriously become Compromised.
639        let state = CustomerState::from_wire_with_source_state_and_origin(
640            "DM_THRESHOLD_BLOCK",
641            None,
642            Some("rule_cors_deny_old"),
643        );
644        assert_eq!(state, CustomerState::BlockedByPolicy);
645    }
646
647    #[test]
648    fn cleanlib_855_from_wire_with_source_state_and_origin_none_matches_plain_from_wire() {
649        // No matched_rule_id at all (pre-855 App, or a non-policy source) must
650        // behave IDENTICALLY to the pre-855 from_wire_with_source_state --
651        // this is the byte-identical-on-the-common-path guarantee.
652        for src in ALL_VERDICT_SOURCES {
653            assert_eq!(
654                CustomerState::from_wire_with_source_state_and_origin(src, None, None),
655                CustomerState::from_wire_with_source_state(src, None),
656                "no matched_rule_id must never change behavior for source {src}"
657            );
658        }
659    }
660
661    #[test]
662    fn cleanlib_855_curated_compromise_never_fires_on_a_non_block_source() {
663        // A curated-looking rule id paired with a NON-DM_THRESHOLD_BLOCK
664        // source (shouldn't happen in practice, but defensive) must not
665        // spuriously produce Compromised -- from_block_origin only
666        // special-cases the exact ("DM_THRESHOLD_BLOCK", true) pair.
667        let state = CustomerState::from_wire_with_source_state_and_origin(
668            "CVE_FINDING",
669            None,
670            Some("supply-chain-compromise-bridge-whatever"),
671        );
672        assert_eq!(state, CustomerState::Vulnerable);
673    }
674
675    #[test]
676    fn cleanlib_855_source_state_takes_precedence_over_source_for_origin_resolution() {
677        // Mirrors from_wire_with_source_state's own precedence: when
678        // source_state is present and non-empty, it -- not the legacy
679        // `source` -- is what the block-origin override resolves against.
680        let state = CustomerState::from_wire_with_source_state_and_origin(
681            "VECTOR_VERDICT", // legacy source, would NOT match DM_THRESHOLD_BLOCK
682            Some("DM_THRESHOLD_BLOCK"),
683            Some("supply-chain-compromise-bridge-event-stream-2018-mh6f-8j2x-4483"),
684        );
685        assert_eq!(state, CustomerState::Compromised);
686    }
687}