cleanlib_client/types.rs
1//! Verdict + ancillary response types per Client spec rev1 §2.4 +
2//! App Rev 4 §4.1 Vector verdict shape.
3//!
4//! All fields default-tolerant via `#[serde(default)]` so the SDK can
5//! consume partial responses during cycle-3 → cycle-N spec evolution
6//! without forcing a recompile-and-redeploy on every App-side schema
7//! widening.
8
9use serde::{Deserialize, Serialize};
10
11/// `Verdict` mirrors App Rev 4 §4.1 `Verdict` struct surfaced via
12/// `GET /v1/customer/verdicts/{ecosystem}/{package}/{version}`.
13///
14/// Cycle-9 R1 fix-forward Lane-2 M1: adds `severity` + `decision` to align
15/// with the App-canonical envelope shape (sister of `cleanlib-core::Verdict`
16/// + js/py/go SDK envelope carrying). All new fields are `Option<String>`
17/// to preserve serde-default tolerance — pre-R1 verdict payloads (without
18/// these fields) deserialize cleanly with `None`. Sister-shape with the
19/// `VerdictEnvelopeV1` schema-locked at `cleanlib-contract-fixtures@v1.0.0`.
20/// CLEANLIB-468 tolerant deserializer for the `verdict` label field — see the
21/// field doc on [`Verdict::verdict`]. Accepts a flat string (scan / cache / v1)
22/// or the envelope-v2 nested object (returns its `type`). Format-aware so bincode
23/// (non-self-describing) stays a plain positional string read.
24fn de_verdict_label<'de, D>(deserializer: D) -> Result<String, D::Error>
25where
26 D: serde::Deserializer<'de>,
27{
28 struct LabelVisitor;
29
30 impl<'de> serde::de::Visitor<'de> for LabelVisitor {
31 type Value = String;
32
33 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
34 f.write_str("a verdict label string or an envelope-v2 {type,…} object")
35 }
36
37 fn visit_str<E>(self, v: &str) -> Result<String, E> {
38 Ok(v.to_string())
39 }
40
41 fn visit_string<E>(self, v: String) -> Result<String, E> {
42 Ok(v)
43 }
44
45 // CLEANLIB-518 (§2): a null verdict label must NOT reject the whole
46 // parse (was `invalid type: null, expected a string` — Test 743061).
47 // Degrade tolerantly to the empty label, which the status/reason mapper
48 // then resolves to the fail-closed (WARN, VERDICT_NOT_YET_ASSESSED)
49 // default — byte-parity with sdk-py/js/go, which already tolerate a
50 // null/absent label. serde_json routes JSON `null` to `visit_unit`;
51 // `visit_none` covers Option-wrapped deserializers for completeness.
52 fn visit_unit<E>(self) -> Result<String, E> {
53 Ok(String::new())
54 }
55
56 fn visit_none<E>(self) -> Result<String, E> {
57 Ok(String::new())
58 }
59
60 // Envelope-v2 Path-A nested object → return its `type`; ignore the rest.
61 fn visit_map<A>(self, mut map: A) -> Result<String, A::Error>
62 where
63 A: serde::de::MapAccess<'de>,
64 {
65 let mut label = String::new();
66 while let Some(key) = map.next_key::<String>()? {
67 if key == "type" {
68 label = map.next_value::<String>()?;
69 } else {
70 let _ = map.next_value::<serde::de::IgnoredAny>()?;
71 }
72 }
73 Ok(label)
74 }
75 }
76
77 // JSON (self-describing) can branch on the actual value; bincode cannot do
78 // `deserialize_any`, so read it as the plain positional string it was stored as.
79 if deserializer.is_human_readable() {
80 deserializer.deserialize_any(LabelVisitor)
81 } else {
82 deserializer.deserialize_string(LabelVisitor)
83 }
84}
85
86#[derive(Debug, Clone, Deserialize, Serialize)]
87#[serde(default)]
88pub struct Verdict {
89 pub verdict_id: String,
90 /// `ALLOWED_NO_FINDINGS` | `VECTOR_VERDICT` | `DM_THRESHOLD_BLOCK` |
91 /// `INSUFFICIENT_DATA` per locked Verdict-label enum.
92 ///
93 /// CLEANLIB-468: tolerant deserialize. The envelope-v2 customer-verdict wire
94 /// (Path A) sends `verdict` as a nested OBJECT `{type,status,customer_state}`,
95 /// while `POST /v1/scan` (`ScanResult`) and the bincode verdict cache
96 /// send/store it as a flat STRING. A bare `String` field errored on the
97 /// object — `invalid type: map, expected a string` — the CLEANLIB-462 live
98 /// CLI break. [`de_verdict_label`] accepts EITHER form (object → its `type`;
99 /// string → as-is) and is format-aware via `is_human_readable`: JSON uses the
100 /// string-or-map visitor, bincode (non-self-describing, positional) uses
101 /// `deserialize_string` so the cache round-trip is unaffected. Serialize is
102 /// unchanged (emits the flat string). No dependency on the App `verdict_label`
103 /// field or on deploy ordering.
104 #[serde(deserialize_with = "de_verdict_label")]
105 pub verdict: String,
106 pub source: String,
107 pub confidence: f64,
108 pub composite_score: u8,
109 pub reasoning: String,
110 pub similar_to: Vec<String>,
111 pub evidence_gaps: Vec<String>,
112 pub suggested_actions: Vec<String>,
113 pub data_freshness_at: Option<String>,
114 pub data_oldest_signal_at: Option<String>,
115 pub stale_since_at: Option<String>,
116 pub staleness_reason: Option<String>,
117 pub computed_at: Option<String>,
118 /// App-canonical severity tier (`NONE` | `LOW` | `MEDIUM` | `HIGH` |
119 /// `CRITICAL` per `cleanlib-core::Severity`). Cycle-9 Lane-2 M1 close.
120 /// `Option<String>` for serde-default tolerance against pre-M1 payloads.
121 pub severity: Option<String>,
122 /// Coarse gating decision (`ALLOW` | `WARN` | `DENY` |
123 /// `RISK_ACCEPTANCE_REQUIRED`). Sister of js/py/go SDK carrying.
124 /// Cycle-9 Lane-2 M1 close. Optional for serde-default tolerance.
125 #[serde(alias = "policy_decision")]
126 pub decision: Option<String>,
127 /// Prior-verdict comparison shape; envelope emits `null` when no prior
128 /// verdict exists. v0.1.3 parity-ripple with `sdk-go::PreviousVerdict`
129 /// (cycle-13 M1' ship). NOTE: no `skip_serializing_if` — Verdict is
130 /// bincode-serialized by `cleanlib-cli` PersistentCache, which is
131 /// positional and breaks if fields are conditionally omitted. JSON
132 /// consumers see `previous_verdict: null` which matches the App's
133 /// canonical envelope shape.
134 pub previous_verdict: Option<PreviousVerdict>,
135 /// Cycle-15 observability honesty signal. Non-Optional per CLEANLIB-104
136 /// App-3.1 Gate M3 flip (2026-07-01). Sister of
137 /// `cleanlib_core::AvailabilityBlock`.
138 ///
139 /// Serde `#[serde(default)]` at the struct level (line 21) provides
140 /// fail-open: pre-M3 payloads omitting the `availability` key
141 /// deserialize to `AvailabilityBlock::default()` (`degraded_stale = false`).
142 /// This preserves compatibility with pre-cycle-15 payloads AND happy-path
143 /// verdicts that previously omitted the block via `None`.
144 pub availability: AvailabilityBlock,
145
146 // ─── CLEANLIB-412 envelope-v2 (Step-6 phase-a struct prep) ───────────────
147 // Additive top-level fields per the BD-ratified emit-boundary contract
148 // (CLEANLIB-377 [STEP-EMIT-DRAFT-3] 721058; micro-1 721008 / micro-2 721024).
149 // All `Option` + the struct-level `#[serde(default)]` above → pre-envelope-v2
150 // (v1) payloads deserialize with `None` (back-compat), and forward-compat holds
151 // with NO `deny_unknown_fields` (canvas §7 anti-pattern #7). Wire values stay
152 // String; typed parsing (customer_state → STATE_META) remains in
153 // `customer_state.rs` via `CustomerState::from_wire`, so an unknown future 9th
154 // value never fails the reader. Appended at the tail to keep the bincode
155 // (cleanlib-cli PersistentCache) field order stable for existing entries;
156 // see PR note re: cache invalidation on struct-shape change.
157 /// Envelope schema version — `2` on envelope-v2 responses, `None` on v1.
158 pub envelope_version: Option<u32>,
159 /// Shipped CLEANLIB-178 OUTPUT display taxonomy (`clean` … `blocked_by_policy`),
160 /// hoisted server-side so the client skips `from_wire` on the happy path.
161 pub customer_state: Option<String>,
162 /// Coarse client UX status (`BLOCKED` | `WARN` | `ALLOWED` | `UNKNOWN` |
163 /// `RISK_ACCEPTANCE_REQUIRED`).
164 pub state: Option<String>,
165 /// Producer wire 8-enum (`DM_THRESHOLD_BLOCK` …) — canonical forward name for
166 /// `source` (retained above for v1 back-compat).
167 pub source_state: Option<String>,
168 /// Active policy bundle version, promoted to top-level in v2.
169 pub policy_version: Option<String>,
170 /// FK (ULID) to the frozen WORM audit record.
171 pub audit_record_id: Option<String>,
172 /// hex SHA-256 — tamper-evident binding to the audit record's `content_hash`.
173 pub audit_record_hash: Option<String>,
174 /// CLEANLIB-496 (C1): the signed attestation the App emits — the full
175 /// `SignedAttestation` object `{attestation:{…10 fields…}, signature_b64,
176 /// key_id}`. Carried as a passthrough `Value` (not a mirrored typed struct)
177 /// so the CLI `--output json`/`--output sarif` can surface it verbatim for
178 /// `verify_attestation.py` without duplicating the cosign-signer schema.
179 /// `None` on v1 / unsigned responses.
180 pub attestation: Option<serde_json::Value>,
181 /// CLEANLIB-518 §4: enhanced-verdict `evidence[]` the App emits under the
182 /// `CLEANLIB_ENHANCED_VERDICT` flag (typed `Evidence` items in stream1).
183 /// Carried as a passthrough `Value` (shape-agnostic) — same tolerant
184 /// discipline as `attestation` above — so `verdict_to_envelope` can surface
185 /// it verbatim into `rich_data.evidence` ahead of the A-anchor shape
186 /// finalizing (CLEANLIB-525). A rigid typed `Vec<Evidence>` can replace this
187 /// later without a wire break (additive: unknown fields already tolerated,
188 /// no `deny_unknown_fields`). `None` when the flag is off / on v1.
189 pub evidence: Option<serde_json::Value>,
190 /// CLEANLIB-518 §4: enhanced-verdict `composition{}` object (dependency /
191 /// provenance composition breakdown) the App emits under the same flag.
192 /// Same passthrough discipline as `evidence` above; surfaced verbatim into
193 /// `rich_data.composition`. `None` when absent.
194 pub composition: Option<serde_json::Value>,
195 /// CLEANLIB-601 (Option B): the App's typed `rich_data` block, preserved so
196 /// the CLI `fix` command can read `rich_data.recommended_version` as a typed
197 /// field instead of parsing the `suggested_actions` marker string (Option A).
198 /// Both A+B are defense-in-depth: the typed field lands cleanly, and the
199 /// marker survives even if the App refactors the struct. `None` on v1 /
200 /// responses without the block. Appended at the tail to keep bincode
201 /// (cleanlib-cli PersistentCache) field order stable for existing entries;
202 /// the cache `get()` self-heals on struct-shape change (deser-fail → miss).
203 pub rich_data: Option<RichData>,
204 /// CLEANLIB-613: the App's `top_findings` block — the per-CVE findings that
205 /// back a VECTOR_VERDICT/DENY, each carrying a structured `fixed_version`.
206 /// This is the App's ALWAYS-populated remediation source, unlike the
207 /// flag-gated `rich_data.recommended_version` (Option B) which was dark in
208 /// prod. Previously UNMODELED — so the CLI `fix` command discarded the App's
209 /// own answer at deserialize and produced `no_recommendation` for known-
210 /// vulnerable packages while exiting 0 (a fail-open). `fix` now reads the
211 /// cumulative (max) `top_findings.findings[].fixed_version` as the upgrade
212 /// target. Tolerant-passthrough: `Option` + struct-level `#[serde(default)]`,
213 /// no `deny_unknown_fields`, so a v1 payload or a future field never fails the
214 /// reader. Appended at the tail to keep the bincode (PersistentCache) field
215 /// order stable; the cache `get()` self-heals on struct-shape change.
216 pub top_findings: Option<TopFindings>,
217 /// CLEANLIB-652 (CX-3) part 1: machine-stable reason a verdict is non-clean,
218 /// hoisted top-level on envelope-v2 (App `reason_class`). Exactly one of
219 /// `CVE_AFFECTING` | `CVE_ON_KEV` | `CVE_ON_RANSOMWARE` | `MALICIOUS_TRIAGE` |
220 /// `POLICY_DENY` | `INSUFFICIENT_DATA` | `RANGE_NOT_RESOLVED`, or `None` on a
221 /// clean verdict (no reason owed) and on v1. Values are already codename-clean
222 /// (e.g. `POLICY_DENY`, not the engine type name) so the MACHINE surface passes
223 /// them through verbatim; the HUMAN surface maps to friendly text via
224 /// [`crate::customer_state`]-style rendering in `cleanlib-cli`, with a safe
225 /// generic for any unknown future value (forward-compat — no
226 /// `deny_unknown_fields`). Distinct from `errors::Problem.reason_class`, which
227 /// is an RFC-7807 error-branch key on the error path.
228 pub reason_class: Option<String>,
229 /// CLEANLIB-652 (CX-3) part 1: renderable attestation status (App
230 /// `attestation_status`) — `signed` | `signature_absent` as emitted by the App.
231 /// `signature_invalid` is reserved for the CLIENT to set when local verification
232 /// of [`Verdict::attestation`] fails (producer/consumer split). `None` on v1.
233 /// Machine surface passthrough; human surface maps to Signed / Unsigned /
234 /// Invalid signature. Appended at the tail to keep the bincode (PersistentCache)
235 /// field order stable for existing entries.
236 pub attestation_status: Option<String>,
237 /// CLEANLIB-652 (CX-3) part 2: per-axis data freshness — the App's nested
238 /// `freshness` block (App `verbs::Freshness`) with SEPARATE ages for the CVE,
239 /// behavioral, and policy axes, plus `stalest_axis` and `overall_as_of` (= MIN
240 /// of the non-null axis ages, Client-confirmed 652 c767287). Additive: it
241 /// ENRICHES the existing flat `data_freshness_at` / `data_oldest_signal_at` /
242 /// `stale_since_at` fields, it does not replace them. `None` on v1 / pre-part-2.
243 /// Tail-appended to keep the bincode (PersistentCache) field order stable.
244 #[serde(default)]
245 pub freshness: Option<Freshness>,
246 /// CLEANLIB-652 (CX-3) part 3a: structured remediation — a single upgrade
247 /// target (App `verbs::Remediation`) so the client renders an actionable
248 /// upgrade without parsing prose. `None` when no safe upgrade exists (honest —
249 /// matches the App's "no fix to recommend" semantics, distinct from
250 /// `top_findings[].fixed_version` / `rich_data.recommended_version` which `fix`
251 /// still uses). Tail-appended for bincode field-order stability.
252 #[serde(default)]
253 pub remediation: Option<Remediation>,
254 /// CLEANLIB-780: the per-axis result envelope (App `axes`), reporting the
255 /// advisory / threat / availability planes SEPARATELY so a consulted-and-empty
256 /// advisory reads as an honest GREEN ("checked, none found") rather than an
257 /// alarmist data-gap, and a behavioral-malicious signal is not collapsed into
258 /// the advisory verdict. v2-gated + skip-None on the wire → `None` on v1.
259 /// Tail-appended for bincode (PersistentCache) field-order stability; the cache
260 /// `get()` self-heals on struct-shape change (deser-fail → miss). See [`Axes`].
261 #[serde(default)]
262 pub axes: Option<Axes>,
263 /// CLEANLIB-855: the policy rule ID that produced a `DM_THRESHOLD_BLOCK`/
264 /// `DM_THRESHOLD_WARN` verdict, when one did. `None` on every other
265 /// source (Vector/CVE/absence paths never match a customer policy rule)
266 /// and on pre-855 App builds. A curated `supply-chain-compromise-bridge-*`
267 /// prefix distinguishes a `Compromised` render from the generic
268 /// `BlockedByPolicy` — see [`crate::customer_state::CustomerState::
269 /// from_block_origin`] and [`crate::customer_state::is_curated_supply_chain_compromise`].
270 /// Tail-appended for bincode (PersistentCache) field-order stability.
271 #[serde(default)]
272 pub matched_rule_id: Option<String>,
273}
274
275/// CLEANLIB-652 (CX-3) part 3a: structured remediation (mirrors App
276/// `verbs::Remediation`) — the single upgrade target + a per-ecosystem copy-paste
277/// command. `target_version` is engine-clean (the composite's effective fix
278/// version). All-string + `#[serde(default)]` for tolerant, forward-compat parse.
279#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
280#[serde(default)]
281pub struct Remediation {
282 /// The version to upgrade to (upgrading clears every CVE the composite can).
283 pub target_version: String,
284 /// Per-ecosystem copy-paste upgrade command for the target.
285 pub command_hint: String,
286}
287
288/// CLEANLIB-652 (CX-3) part 2: the App's nested per-axis `freshness` block
289/// (mirrors `verbs::Freshness`). Each axis age is `Option` — under precedence
290/// composition only the producing axis carries a timestamp and the others are
291/// null; `overall_as_of` = MIN of the non-null axis ages (the verdict is only as
292/// fresh as its stalest input). All-optional + `#[serde(default)]` for tolerant,
293/// forward-compatible parsing.
294#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
295#[serde(default)]
296pub struct Freshness {
297 pub cve_data_at: Option<String>,
298 pub behavioral_data_at: Option<String>,
299 pub policy_evaluated_at: Option<String>,
300 /// Which axis is stalest — drives the `overall_as_of` age.
301 pub stalest_axis: Option<String>,
302 /// MIN of the non-null per-axis ages.
303 pub overall_as_of: Option<String>,
304}
305
306/// CLEANLIB-613: the App's `top_findings` block on a customer verdict — the
307/// per-CVE findings that back a VECTOR_VERDICT/DENY, plus the KEV / ransomware
308/// flags. Modeled so the CLI `fix` command can reach each finding's structured
309/// `fixed_version` (the App's always-populated remediation target). Tolerant:
310/// `#[serde(default)]`, all-optional, no `deny_unknown_fields`.
311#[derive(Debug, Clone, Deserialize, Serialize, Default)]
312#[serde(default)]
313pub struct TopFindings {
314 /// Total CVE count backing this verdict (may exceed `findings.len()` when the
315 /// App truncates to the top-N; the summary still counts them all).
316 pub cve_count: Option<u32>,
317 pub on_kev: Option<bool>,
318 pub on_ransomware: Option<bool>,
319 /// The per-CVE findings. Each carries its own `fixed_version`; the CLI `fix`
320 /// command takes the max across these as the cumulative upgrade target.
321 pub findings: Vec<Finding>,
322}
323
324/// CLEANLIB-613: one CVE finding inside [`TopFindings`]. Only the fields the CLI
325/// consumes are typed; the App may add more (tolerated — no `deny_unknown_fields`).
326#[derive(Debug, Clone, Deserialize, Serialize, Default)]
327#[serde(default)]
328pub struct Finding {
329 pub cve_id: Option<String>,
330 /// The version that remediates THIS CVE. `fix` takes the max of these across
331 /// findings as the cumulative upgrade target (you must reach at least the
332 /// highest per-CVE fix to clear every CVE). `None` when no fix exists yet.
333 pub fixed_version: Option<String>,
334 pub vulnerable_versions: Option<String>,
335 pub severity: Option<String>,
336 pub cvss_v3_score: Option<f64>,
337}
338
339/// CLEANLIB-601: typed subset of the App's `rich_data` block. Carries the
340/// `recommended_version` upgrade target for the CLI `fix` command. `#[serde(default)]`
341/// + no `deny_unknown_fields` — other `rich_data` keys (evidence/composition, which
342/// the App also hoists to top-level passthrough fields above) are tolerated, and a
343/// `rich_data` object missing `recommended_version` deserializes to `None`.
344#[derive(Debug, Clone, Deserialize, Serialize, Default)]
345#[serde(default)]
346pub struct RichData {
347 /// The App's suggested upgrade target (e.g. `"4.17.21"`). `None` when the
348 /// App emits no recommendation for this coordinate.
349 pub recommended_version: Option<String>,
350}
351
352/// Cycle-15 honesty signal block on the SDK Verdict shape. Mirrors the App
353/// wire-shape `cleanlib_core::AvailabilityBlock`. `Option<bool>`-style
354/// passthrough for `degraded_stale` so pre-cycle-15 payloads (without the
355/// block) deserialize cleanly.
356///
357/// CLEANLIB-105 App-3.2 M1/M2 additions: `kev` / `epss` /
358/// `exploitation_fusion` sub-fields as `Option<String>` (SDK-passthrough
359/// per §5 ripple discipline). String tags: `"available"` |
360/// `"not_applicable"` | `"unavailable"` | `"degraded_stale"` per
361/// `cleanlib_core::FieldAvailability` snake_case serde. `Option` on the
362/// SDK side (vs `FieldAvailability` non-Optional on the App side) lets
363/// pre-M1 payloads without any sub-field key deserialize cleanly to
364/// `None` — the SDK's `derive_status.rs` treats `None` and
365/// `"unavailable"` identically (both fail the "== Some(\"available\")"
366/// check on lines 76+).
367///
368/// NOTE: no `skip_serializing_if` on any field — this struct is bincode-
369/// serialized (positionally) by `cleanlib-cli::PersistentCache`, and
370/// conditional omission would corrupt the cache alignment (§CLEANLIB-104
371/// design doc §3.M3 cache-shape note). The parent `Verdict` documents this
372/// invariant at the `previous_verdict` field. Fields that need to be omitted
373/// from the customer-facing JSON envelope are re-shaped by
374/// [`crate::verdict_to_envelope::verdict_to_envelope_v1`] (which is the
375/// customer wire path), not by field-level serde attributes here.
376#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
377#[serde(default)]
378pub struct AvailabilityBlock {
379 pub degraded_stale: bool,
380 /// CISA KEV substrate availability tag
381 /// (`"available"` | `"not_applicable"` | `"unavailable"` | `"degraded_stale"`).
382 pub kev: Option<String>,
383 /// FIRST.org EPSS substrate availability tag.
384 pub epss: Option<String>,
385 /// Composite exploitation-likelihood availability tag.
386 pub exploitation_fusion: Option<String>,
387}
388
389/// CLEANLIB-780: the per-axis result envelope (mirrors the App's `axes` on
390/// `CustomerVerdictResponse`). Each plane is reported SEPARATELY so the client
391/// renders honest per-axis language. Tolerant: struct-level `#[serde(default)]` +
392/// `Default`, no `deny_unknown_fields`, so a partial or future-extended envelope
393/// never fails the reader.
394#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
395#[serde(default)]
396pub struct Axes {
397 pub advisory: AxisAdvisory,
398 pub threat: AxisThreat,
399 pub availability: AxisAvailability,
400}
401
402/// CLEANLIB-780 advisory (CVE cross-reference) axis. `result` is the App's
403/// closed enum `clean` | `vulnerable` | `never_consulted` — DERIVED, never a
404/// false clean. `sources_consulted` lists ONLY sources proven to have
405/// contributed data and is ABSENT on a clean/empty result (the App does not
406/// fabricate a source list), so the client must render only the sources present
407/// and never a hardcoded set.
408#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
409#[serde(default)]
410pub struct AxisAdvisory {
411 pub ran: bool,
412 pub result: String,
413 #[serde(skip_serializing_if = "Option::is_none")]
414 pub sources_consulted: Option<Vec<String>>,
415 #[serde(skip_serializing_if = "Option::is_none")]
416 pub consulted_at: Option<String>,
417 pub advisory_count: usize,
418}
419
420/// CLEANLIB-780 threat (behavioral / Vector triage) axis — the empirical split
421/// from advisory. `result` is `malicious` | `blocked` | `clean` |
422/// `never_consulted`. CVE findings are the ADVISORY axis and are NOT counted here.
423#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
424#[serde(default)]
425pub struct AxisThreat {
426 pub ran: bool,
427 pub result: String,
428 pub finding_count: usize,
429 #[serde(skip_serializing_if = "Option::is_none")]
430 pub evaluated_at: Option<String>,
431}
432
433/// CLEANLIB-780 availability (byte-serve fingerprint) axis. Not evaluated on the
434/// verdict path, so `ran` is honestly `false` — a scope statement, not a failure.
435#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
436#[serde(default)]
437pub struct AxisAvailability {
438 pub ran: bool,
439 pub result: String,
440}
441
442/// Prior-verdict comparison. Surfaces when the CleanLibrary App has a
443/// stored prior verdict for the same `(ecosystem, package, version)` that
444/// differs from the current one — useful for AI agents and dashboards
445/// that want to flag verdict-state changes since the last fetch.
446/// Sister-shape with `cleanlib_sdk_go::PreviousVerdict` and
447/// `cleanlib-core::PreviousVerdict` in the App.
448#[derive(Debug, Clone, Default, Deserialize, Serialize)]
449#[serde(default)]
450pub struct PreviousVerdict {
451 pub verdict_id: String,
452 pub verdict: String,
453 pub computed_at: String,
454 pub diff: String,
455}
456
457impl Default for Verdict {
458 fn default() -> Self {
459 Self {
460 verdict_id: String::new(),
461 verdict: String::new(),
462 source: String::new(),
463 confidence: 0.0,
464 composite_score: 0,
465 reasoning: String::new(),
466 similar_to: Vec::new(),
467 evidence_gaps: Vec::new(),
468 suggested_actions: Vec::new(),
469 data_freshness_at: None,
470 data_oldest_signal_at: None,
471 stale_since_at: None,
472 staleness_reason: None,
473 computed_at: None,
474 severity: None,
475 decision: None,
476 previous_verdict: None,
477 availability: AvailabilityBlock::default(),
478 // CLEANLIB-412 envelope-v2 (Step-6 phase-a) — absent on v1.
479 envelope_version: None,
480 customer_state: None,
481 state: None,
482 source_state: None,
483 policy_version: None,
484 audit_record_id: None,
485 audit_record_hash: None,
486 attestation: None,
487 // CLEANLIB-518 §4 tolerant-passthrough — absent unless the App
488 // enhanced-verdict flag is on.
489 evidence: None,
490 composition: None,
491 // CLEANLIB-601 — absent unless the App emits a rich_data block.
492 rich_data: None,
493 // CLEANLIB-613 — absent on v1; populated on customer-verdict responses.
494 top_findings: None,
495 // CLEANLIB-652 (CX-3) part 1 — absent on v1; App emits on envelope-v2.
496 reason_class: None,
497 attestation_status: None,
498 // CLEANLIB-652 (CX-3) part 2 — nested per-axis freshness; absent on v1.
499 freshness: None,
500 // CLEANLIB-652 (CX-3) part 3a — structured remediation; absent on v1.
501 remediation: None,
502 // CLEANLIB-780 — per-axis result envelope; absent on v1.
503 axes: None,
504 // CLEANLIB-855 — matched policy rule; absent on v1 and on any
505 // non-policy verdict source.
506 matched_rule_id: None,
507 }
508 }
509}
510
511/// One package identity for policy-preview / scan requests.
512///
513/// Wire-contract note: the App-side coordinate struct
514/// (`cleanlib-app::verbs::PackageRef`, shared by `POST /v1/scan` +
515/// `POST /v1/policy/preview`) names this field `package`, not `name`.
516/// Serializing the Rust identifier `name` verbatim made the App reject the
517/// body with `422 … packages[0]: missing field \`package\``, breaking both
518/// `cleanlib scan` and `cleanlib policy preview`. The `#[serde(rename)]` puts
519/// `package` on the wire while keeping the `name` identifier that the
520/// packages-file parsers in `commands::scan` already construct.
521#[derive(Debug, Clone, Deserialize, Serialize)]
522pub struct PackageRef {
523 pub ecosystem: String,
524 #[serde(rename = "package")]
525 pub name: String,
526 pub version: String,
527}
528
529/// Body of `POST /v1/policy/preview` — packages + optional
530/// hypothetical policy override (JSON-shaped; YAML-source customers
531/// convert client-side).
532#[derive(Debug, Clone, Serialize)]
533pub struct PolicyPreviewRequest {
534 pub packages: Vec<PackageRef>,
535 #[serde(skip_serializing_if = "Option::is_none")]
536 pub policy: Option<serde_json::Value>,
537}
538
539/// Per-package decision returned from `/v1/policy/preview` or
540/// embedded in audit entries.
541#[derive(Debug, Clone, Deserialize, Serialize, Default)]
542#[serde(default)]
543pub struct PolicyDecision {
544 pub ecosystem: String,
545 pub package: String,
546 pub version: String,
547 /// `ALLOW` | `DENY` | `WARN` | `INSUFFICIENT_DATA` | `RISK_ACCEPTANCE_REQUIRED`
548 pub decision: String,
549 /// CLEANLIB-666: the `/v1/policy/preview` wire names this `reasoning`
550 /// (`verbs::PolicyPreviewResult.reasoning`); scan/audit surfaces use `reason`.
551 /// Alias reads both so the same struct parses every producer.
552 #[serde(alias = "reasoning")]
553 pub reason: String,
554 pub verdict_id: Option<String>,
555 /// CLEANLIB-666: `/v1/policy/preview` names the matched rule `matched_rule_id`
556 /// on the wire; other surfaces use `policy_rule_id`. Alias reads both.
557 #[serde(alias = "matched_rule_id")]
558 pub policy_rule_id: Option<String>,
559 /// CLEANLIB-616: set when this coordinate could NOT be evaluated — a per-package
560 /// scan error, or a chunk that failed/returned no result (mirrors
561 /// `ScanResult.error`). Lets `--output json` distinguish a NEVER-EVALUATED
562 /// coordinate from one that was evaluated and warned: both surface as a WARN
563 /// `decision`, but only the unevaluated one carries `error`. `None` (and
564 /// omitted from JSON) on an evaluated coordinate.
565 #[serde(skip_serializing_if = "Option::is_none")]
566 pub error: Option<String>,
567}
568
569/// Response from `POST /v1/policy/preview`.
570#[derive(Debug, Clone, Deserialize, Serialize, Default)]
571#[serde(default)]
572pub struct PolicyPreviewResponse {
573 /// CLEANLIB-666: the App emits this array as `results`
574 /// (`verbs::PolicyPreviewResponse.results`), not `decisions`. Without the
575 /// alias the client parsed every real preview response into an EMPTY vec —
576 /// the verb silently returned nothing (exit 0) even after CLEANLIB-631/#362
577 /// fixed the request side. Alias makes the client read the real wire.
578 #[serde(alias = "results")]
579 pub decisions: Vec<PolicyDecision>,
580 /// CLEANLIB-666 residual: the App emits `policy_version` alongside `results`
581 /// (`verbs::PolicyPreviewResponse.policy_version`) — the version of the policy
582 /// these decisions were evaluated against. The response struct previously had
583 /// no field for it, so it was silently dropped (gate376 flagged
584 /// `policy_version DROPPED`). Capture it so `--output json` faithfully reports
585 /// which policy version produced the decisions. `#[serde(default)]` on the
586 /// struct keeps this back-compat for responses that omit it (→ `None`).
587 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub policy_version: Option<String>,
589 /// CLEANLIB-480 · the `x-request-id` header the App emits on every
590 /// response (per CLEANLIB-470, live on all 4 response classes). Populated
591 /// from the RESPONSE HEADER by `transport::Client::policy_preview` after
592 /// the JSON body has been parsed — the wire body itself carries no
593 /// `request_id` field (`#[serde(default)]` → `None` on deserialize).
594 /// SDK callers doing correlation debugging (e.g. partner reporting an
595 /// issue, log correlation) read this directly instead of falling back
596 /// to a raw HTTP client bypass.
597 #[serde(default, skip_serializing_if = "Option::is_none")]
598 pub request_id: Option<String>,
599}
600
601/// Body of `POST /v1/scan` — a batch of package coordinates, no policy.
602///
603/// Distinct from [`PolicyPreviewRequest`]: `cleanlib scan` previews packages
604/// against the customer's *active* policy (verdict-driven, server-side), so it
605/// carries no `policy_yaml`. Routing `scan` through `/v1/policy/preview`
606/// (which requires `policy_yaml`) was the 422 that hid behind the earlier
607/// `package`-field fix.
608#[derive(Debug, Clone, Serialize)]
609pub struct ScanRequest {
610 pub packages: Vec<PackageRef>,
611}
612
613/// One entry of the `POST /v1/scan` response. Mirrors the App's
614/// `verbs::ScanResult` wire shape: the package coordinate is flattened
615/// (`ecosystem` / `package` / `version`) alongside an optional `verdict`
616/// (present on success) or `error` string (per-package partial failure —
617/// the App resolves each package independently and never fails the whole
618/// batch on one miss).
619#[derive(Debug, Clone, Deserialize, Serialize, Default)]
620#[serde(default)]
621pub struct ScanResult {
622 pub ecosystem: String,
623 pub package: String,
624 pub version: String,
625 pub verdict: Option<Verdict>,
626 pub error: Option<String>,
627 // ─── CLEANLIB-652 [SibSurface] (#372): per-package v2 envelope fields ─────
628 // App #372 flattens a ScanVerdictEnvelope onto each ScanResult, so these ride
629 // as SIBLINGS of `verdict` (which stays nested) — the client dropped them
630 // before this. All `Option` + the struct-level `#[serde(default)]` → v1 / thin
631 // results deserialize `None` (back-compat), no `deny_unknown_fields`. Names
632 // mirror the App wire exactly. Reuses [`Freshness`]/[`Remediation`] (CX-3
633 // part-2/3a). Surfaced on the scan surface via `decision_from_result`.
634 pub customer_state: Option<String>,
635 pub state: Option<String>,
636 pub source_state: Option<String>,
637 pub reason_class: Option<String>,
638 pub attestation_status: Option<String>,
639 pub freshness: Option<Freshness>,
640 pub remediation: Option<Remediation>,
641}
642
643/// CLEANLIB-652 (CX-3) part 3b / CLEANLIB-647 (DD-1): one per-coordinate
644/// not-assessed reason from the App's per-request `coverage` block. The App emits
645/// this for every coordinate it returned but could NOT assess (see
646/// [`Coverage::not_assessed_reasons`]); the client joins it into the per-decision
647/// `error` field so `scan --output json` can distinguish a NEVER-EVALUATED
648/// coordinate from one that was evaluated and warned.
649#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
650#[serde(default)]
651pub struct NotAssessedReason {
652 /// `"{ecosystem}/{package}@{version}"` — the App's coordinate identity,
653 /// matching the client's own `format!("{}/{}@{}", …)` join key.
654 pub coordinate: String,
655 /// Coverage-scoped reason class (`INSUFFICIENT_DATA` | `RANGE_NOT_RESOLVED`).
656 pub reason_class: String,
657}
658
659/// CLEANLIB-652 (CX-3) part 3b / CLEANLIB-647 (DD-1): the App's per-request
660/// `coverage` block on the `POST /v1/scan` response — how many coordinates were
661/// assessed vs not, plus the per-coordinate attribution the client required
662/// (652 c767287) so DD-1 can populate `error` on each never-evaluated coordinate.
663#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
664#[serde(default)]
665pub struct Coverage {
666 pub assessed: usize,
667 pub not_assessed: usize,
668 /// Per-coordinate reasons; empty (and omitted on the wire) when every
669 /// coordinate was assessed.
670 pub not_assessed_reasons: Vec<NotAssessedReason>,
671}
672
673/// Response from `POST /v1/scan`. One [`ScanResult`] per requested package.
674#[derive(Debug, Clone, Deserialize, Serialize, Default)]
675#[serde(default)]
676pub struct ScanResponse {
677 pub results: Vec<ScanResult>,
678 /// CLEANLIB-652 (CX-3) part 3b: per-request coverage. `None` on v1 / pre-part-3b
679 /// responses (struct-level `#[serde(default)]` → back-compat).
680 pub coverage: Option<Coverage>,
681 /// CLEANLIB-669 [SibSurface-pre-emption]: the active policy-bundle version for
682 /// this scan request — the App emits it TOP-LEVEL on the /v1/scan response
683 /// (sibling of `results`/`coverage`, App PR #378), per-request not per-result.
684 /// Without this field the client silently DROPS it at parse (the same
685 /// App-emit-needs-Client-consume pairing as [SibSurface]). `None` on v1 /
686 /// envelope-v2-off (`#[serde(default)]` → back-compat).
687 pub policy_version: Option<String>,
688 /// CLEANLIB-480 · the `x-request-id` header the App emits on every
689 /// response — populated from the RESPONSE HEADER by
690 /// `transport::Client::scan`. See [`PolicyPreviewResponse::request_id`]
691 /// for the rationale.
692 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub request_id: Option<String>,
694}
695
696/// One audit log entry returned from `GET /v1/audit`.
697///
698/// **CLEANLIB-366 — App wire is source of truth.** Mirrors the App-side
699/// `AuditRow` (cleanlib-audit-clickhouse) which the App serializes into each
700/// element of the `records` array. Prior CLI struct silently dropped fields
701/// because names had drifted (`package` vs App's `package_name`, `version` vs
702/// `package_version`, `decision` vs `policy_decision`, `reason` vs
703/// `reasoning`, `at` vs `request_at`) — with `#[serde(default)]` deserialize
704/// succeeded and every field came back empty. Same class as CLEANLIB-348.
705///
706/// Field names below match `AuditRow` exactly. All fields default-tolerant
707/// via struct-level `#[serde(default)]` so partial responses or App-side
708/// schema evolution do not force a CLI recompile.
709///
710/// UUID fields on the App side (`request_id`, `verdict_id`) serialize as
711/// hyphenated strings; datetime fields (`request_at`, `verdict_at`,
712/// `response_at`, …) serialize as RFC 3339 strings — hence `String` here.
713/// CLEANLIB-794 — defensive [Degr≡Real] guard for the App's `request_id`
714/// placeholder on audit records.
715///
716/// The persisted ClickHouse audit row historically carries no `request_id`
717/// field at all. The App serializer nevertheless emits a nil-UUID
718/// (`"00000000-0000-0000-0000-000000000000"`) as its default, and the SDK
719/// forwards that string to the caller — which then renders it as if it were
720/// a real per-record identifier (the CLI's audit table shows every row with
721/// the same fake ULID; correlation-debugging callers get a value they
722/// mistake for a real backend id). The real fix is on the App (either stamp
723/// a real ULID at write time or omit the field on read); this normalizer is
724/// the SDK-side belt-suspenders so a still-emitting App does not degrade
725/// customer-visible output.
726///
727/// Behaviour: on deserialize, the exact string `00000000-0000-0000-0000-
728/// 000000000000` (the nil-UUID / `Uuid::default().to_string()`) is folded to
729/// the empty string; the empty string is the SDK's honest "no id" marker
730/// (matches how the CLI already renders a missing string field). Any other
731/// value — including a real hyphenated ULID or a hyphenated UUID with real
732/// data — passes through unchanged. This is a strictly-shrinking
733/// transformation: the CLI table cell goes from a fake nil-UUID to blank,
734/// and any downstream caller doing `!id.is_empty()` for "was this stamped?"
735/// gets the correct answer.
736fn deserialize_request_id_normalize_nil<'de, D>(deserializer: D) -> Result<String, D::Error>
737where
738 D: serde::Deserializer<'de>,
739{
740 use serde::Deserialize;
741 // Field may be absent (struct-level `#[serde(default)]` → empty String)
742 // or present as any string. Accept Option<String> so an omitted field
743 // deserializes cleanly, then normalise the nil-UUID sentinel.
744 let raw: Option<String> = Option::deserialize(deserializer)?;
745 let s = raw.unwrap_or_default();
746 if s == "00000000-0000-0000-0000-000000000000" {
747 // The App's placeholder — treat as absent rather than propagate a
748 // fake id the CLI would render alongside real values. Matches the
749 // [Degr≡Real] discipline (a degraded answer must not be
750 // indistinguishable from a real one).
751 Ok(String::new())
752 } else {
753 Ok(s)
754 }
755}
756
757#[derive(Debug, Clone, Deserialize, Serialize, Default)]
758#[serde(default)]
759pub struct AuditEntry {
760 // request identification
761 /// CLEANLIB-794 — the App's `request_id` placeholder (nil-UUID) is
762 /// filtered here on deserialize so the CLI table and any SDK caller see
763 /// the empty string rather than a fake identifier repeated on every row.
764 /// Callers that want to distinguish "stamped" from "absent" check
765 /// `!request_id.is_empty()`; the SDK API shape is unchanged (still
766 /// `String`), so this is a non-breaking robustness lift.
767 #[serde(deserialize_with = "deserialize_request_id_normalize_nil")]
768 pub request_id: String,
769 pub correlation_id: String,
770
771 // request shape
772 pub ecosystem: String,
773 pub package_name: String,
774 pub package_version: String,
775 pub variant: String,
776
777 // decision
778 pub policy_decision: String,
779 pub verdict_id: String,
780 pub verdict_source: String,
781 pub policy_rule_id_matched: String,
782 pub risk_acceptance_status: String,
783 pub reasoning: String,
784
785 // catalog
786 pub gcs_hit: bool,
787
788 // timing (RFC 3339 strings)
789 pub request_at: String,
790 pub verdict_at: String,
791 pub response_at: String,
792
793 // metadata
794 pub app_version: String,
795}
796
797/// Query-window echo returned inside [`AuditResponse::window`]. Mirrors the
798/// App-side `AuditWindow` — echoes the caller's `since` / `until` filter
799/// values verbatim (or `None` when the filter was omitted).
800#[derive(Debug, Clone, Deserialize, Serialize, Default)]
801#[serde(default)]
802pub struct AuditWindow {
803 pub since: Option<String>,
804 pub until: Option<String>,
805}
806
807/// Response from `GET /v1/audit`. Mirrors the App-side `AuditResponse` in
808/// `cleanlib-app::verbs`. See [`AuditEntry`] for the CLEANLIB-366 field-name
809/// alignment note.
810///
811/// `backend_status` is `"wired"` when the App has an `AuditReader` attached
812/// and the read succeeded, `"not_wired"` when no reader is configured, or
813/// `"read_error"` when the reader errored. CLI callers surface this signal
814/// so customers can distinguish "empty because no rows" from "empty because
815/// the audit backend is offline".
816#[derive(Debug, Clone, Deserialize, Serialize, Default)]
817#[serde(default)]
818pub struct AuditResponse {
819 pub window: AuditWindow,
820 pub records: Vec<AuditEntry>,
821 pub record_count: usize,
822 pub per_route: std::collections::BTreeMap<String, usize>,
823 pub backend_status: String,
824 /// CLEANLIB-480 · the `x-request-id` header the App emits on every
825 /// response — populated from the RESPONSE HEADER by
826 /// `transport::Client::audit`. Distinct from [`AuditEntry::request_id`]
827 /// (which is the record-scoped request identifier PERSISTED PER ROW);
828 /// this is the ULID of the current `GET /v1/audit` call that returned
829 /// this response body. See [`PolicyPreviewResponse::request_id`] for
830 /// the rationale.
831 #[serde(default, skip_serializing_if = "Option::is_none")]
832 pub request_id: Option<String>,
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838
839 #[test]
840 fn parses_minimal_verdict() {
841 let json = r#"{
842 "verdict_id": "01JBYK000",
843 "verdict": "ALLOWED_NO_FINDINGS",
844 "source": "ALLOWED_NO_FINDINGS"
845 }"#;
846 let v: Verdict = serde_json::from_str(json).unwrap();
847 assert_eq!(v.verdict_id, "01JBYK000");
848 assert_eq!(v.verdict, "ALLOWED_NO_FINDINGS");
849 assert_eq!(v.confidence, 0.0);
850 assert!(v.similar_to.is_empty());
851 }
852
853 #[test]
854 fn cleanlib_780_v1_verdict_has_no_axes() {
855 // Back-compat: a v1 payload omits `axes` -> None (never a parse error).
856 let json = r#"{"verdict_id":"01A","verdict":"ALLOWED_NO_FINDINGS","source":"ALLOWED_NO_FINDINGS"}"#;
857 let v: Verdict = serde_json::from_str(json).unwrap();
858 assert!(v.axes.is_none());
859 }
860
861 #[test]
862 fn cleanlib_780_parses_axes_consulted_empty_clean() {
863 // The GREEN-flip case: advisory consulted + empty -> result "clean" with
864 // the sources that PROVABLY contributed. Mirrors App's #477 wire exactly.
865 let json = r#"{
866 "verdict_id":"01B","verdict":"ALLOWED_NO_FINDINGS","source":"ALLOWED_NO_FINDINGS",
867 "axes":{
868 "advisory":{"ran":true,"result":"clean","sources_consulted":["cve","nvd","ghsa"],"consulted_at":"2026-09-02T08:00:00Z","advisory_count":0},
869 "threat":{"ran":true,"result":"clean","finding_count":0,"evaluated_at":"2026-09-02T08:00:00Z"},
870 "availability":{"ran":false,"result":"not_run"}
871 }
872 }"#;
873 let v: Verdict = serde_json::from_str(json).unwrap();
874 let axes = v.axes.expect("axes present under v2");
875 assert_eq!(axes.advisory.result, "clean");
876 assert_eq!(axes.advisory.sources_consulted.as_deref(), Some(&["cve".to_string(),"nvd".to_string(),"ghsa".to_string()][..]));
877 assert_eq!(axes.advisory.consulted_at.as_deref(), Some("2026-09-02T08:00:00Z"));
878 assert_eq!(axes.advisory.advisory_count, 0);
879 assert_eq!(axes.threat.result, "clean");
880 assert!(!axes.availability.ran);
881 }
882
883 #[test]
884 fn cleanlib_780_parses_axes_never_consulted_no_sources() {
885 // never_consulted: sources_consulted ABSENT (App does not fabricate a
886 // source list) -> None, so the client renders no source enumeration.
887 let json = r#"{
888 "verdict_id":"01C","verdict":"INSUFFICIENT_DATA","source":"INSUFFICIENT_DATA",
889 "axes":{
890 "advisory":{"ran":false,"result":"never_consulted","advisory_count":0},
891 "threat":{"ran":false,"result":"never_consulted","finding_count":0},
892 "availability":{"ran":false,"result":"not_run"}
893 }
894 }"#;
895 let v: Verdict = serde_json::from_str(json).unwrap();
896 let axes = v.axes.expect("axes present");
897 assert_eq!(axes.advisory.result, "never_consulted");
898 assert!(axes.advisory.sources_consulted.is_none(), "no fabricated source list");
899 assert!(axes.advisory.consulted_at.is_none());
900 }
901
902 /// CLEANLIB-468 regression: the live envelope-v2 Path-A wire — where the
903 /// top-level `verdict` key is a nested OBJECT and the label rides
904 /// `verdict_label` — must deserialize WITHOUT the `invalid type: map,
905 /// expected a string` error that broke Ajeet-Yadav's CLI (CLEANLIB-462).
906 #[test]
907 fn parses_envelope_v2_path_a_wire_with_verdict_object_and_label() {
908 let json = r#"{
909 "verdict_id": "01JBYK042",
910 "verdict": { "type": "VECTOR_VERDICT", "status": "WARN", "customer_state": "vulnerable" },
911 "verdict_label": "VECTOR_VERDICT",
912 "source": "CVE_FINDING",
913 "envelope_version": 2,
914 "customer_state": "vulnerable",
915 "state": "WARN",
916 "source_state": "CVE_FINDING"
917 }"#;
918 // Must NOT error (the nested `verdict` object is skipped; label read from
919 // `verdict_label`).
920 let v: Verdict = serde_json::from_str(json)
921 .expect("Path-A v2 wire must deserialize — CLEANLIB-468");
922 assert_eq!(v.verdict, "VECTOR_VERDICT");
923 assert_eq!(v.source, "CVE_FINDING");
924 assert_eq!(v.customer_state.as_deref(), Some("vulnerable"));
925 assert_eq!(v.source_state.as_deref(), Some("CVE_FINDING"));
926 }
927
928 #[test]
929 fn parses_full_verdict() {
930 let json = r#"{
931 "verdict_id": "01JBYK001",
932 "verdict": "VECTOR_VERDICT",
933 "source": "VECTOR_VERDICT",
934 "confidence": 0.98,
935 "composite_score": 92,
936 "reasoning": "Confirmed malware",
937 "similar_to": ["01JBYK999"],
938 "evidence_gaps": [],
939 "suggested_actions": ["DENY across customers"],
940 "data_freshness_at": "2026-05-21T10:00:00Z",
941 "computed_at": "2026-05-21T10:01:00Z"
942 }"#;
943 let v: Verdict = serde_json::from_str(json).unwrap();
944 assert_eq!(v.composite_score, 92);
945 assert_eq!(v.confidence, 0.98);
946 assert_eq!(v.similar_to.len(), 1);
947 assert_eq!(v.suggested_actions[0], "DENY across customers");
948 }
949
950 #[test]
951 fn cleanlib_613_parses_top_findings_fixed_version() {
952 // Locks the wire contract for the CLI `fix` remediation source. Captured
953 // from cleanapp.clnstrt.dev/v1/customer/verdicts/npm/lodash/4.17.20
954 // (2026-08-20): `verdict` is a nested object, `rich_data` is null, and the
955 // remediation target lives in `top_findings.findings[].fixed_version`.
956 let json = r#"{
957 "verdict_id": "01JBYK613",
958 "verdict": {"customer_state":"vulnerable","status":"WARN","type":"VECTOR_VERDICT"},
959 "source": "CVE_FINDING",
960 "rich_data": null,
961 "suggested_actions": ["Upgrade to 4.18.0+ to address CVE-2026-4800 (HIGH, CVSS 8.1)"],
962 "top_findings": {
963 "cve_count": 6,
964 "on_kev": false,
965 "on_ransomware": false,
966 "findings": [
967 {"cve_id":"CVE-2026-4800","fixed_version":"4.18.0","vulnerable_versions":">=4.0.0,<4.18.0","severity":"HIGH","cvss_v3_score":8.1},
968 {"cve_id":"CVE-2021-23337","fixed_version":"4.17.21","severity":"HIGH"}
969 ]
970 }
971 }"#;
972 let v: Verdict = serde_json::from_str(json).unwrap();
973 // The nested-object `verdict` still deserializes to its `type` (de_verdict_label).
974 assert_eq!(v.verdict, "VECTOR_VERDICT");
975 assert!(v.rich_data.is_none());
976 let tf = v.top_findings.expect("top_findings must parse");
977 assert_eq!(tf.cve_count, Some(6));
978 assert_eq!(tf.findings.len(), 2);
979 assert_eq!(tf.findings[0].fixed_version.as_deref(), Some("4.18.0"));
980 assert_eq!(tf.findings[0].cve_id.as_deref(), Some("CVE-2026-4800"));
981 // A finding omitting optional fields still parses (serde default tolerance).
982 assert_eq!(tf.findings[1].fixed_version.as_deref(), Some("4.17.21"));
983 assert!(tf.findings[1].cvss_v3_score.is_none());
984 }
985
986 #[test]
987 fn parses_policy_preview_response() {
988 let json = r#"{
989 "decisions": [
990 {"ecosystem":"npm","package":"left-pad","version":"1.3.0","decision":"ALLOW","reason":"ok"},
991 {"ecosystem":"npm","package":"event-stream","version":"3.3.6","decision":"DENY","reason":"malware","verdict_id":"01JBYK999"}
992 ]
993 }"#;
994 let resp: PolicyPreviewResponse = serde_json::from_str(json).unwrap();
995 assert_eq!(resp.decisions.len(), 2);
996 assert_eq!(resp.decisions[0].decision, "ALLOW");
997 assert_eq!(resp.decisions[1].decision, "DENY");
998 assert_eq!(resp.decisions[1].verdict_id.as_deref(), Some("01JBYK999"));
999 }
1000
1001 /// CLEANLIB-366 — deserialize against the App's real wire shape
1002 /// (`{window, records, record_count, per_route, backend_status}`) and
1003 /// assert every renamed field (`package_name`, `package_version`,
1004 /// `policy_decision`, `reasoning`, `request_at`) round-trips a non-empty
1005 /// value. The pre-fix struct used `entries` + `{package, version,
1006 /// decision, reason, at}` and silently dropped every field on this
1007 /// payload because names did not match.
1008 #[test]
1009 fn parses_audit_response_matches_app_wire_shape() {
1010 let json = r#"{
1011 "window": {"since": "2026-05-22T00:00:00Z", "until": "2026-05-23T00:00:00Z"},
1012 "records": [{
1013 "request_id": "01936b8f-3c4a-7a12-9c00-000000000001",
1014 "correlation_id": "corr-1",
1015 "customer_ip_hashed": "sha256:aaaa",
1016 "ecosystem": "npm",
1017 "package_name": "lodash",
1018 "package_version": "4.17.21",
1019 "variant": "default",
1020 "user_agent": "cleanlib-cli/0.1.4",
1021 "policy_decision": "ALLOW",
1022 "verdict_id": "01936b8f-3c4a-7a12-9c00-0000000000aa",
1023 "verdict_source": "ALLOWED_NO_FINDINGS",
1024 "policy_rule_id_matched": "rule-42",
1025 "risk_acceptance_status": "NONE",
1026 "reasoning": "ok",
1027 "gcs_hit": true,
1028 "gcs_object_path": "gs://bucket/obj",
1029 "bytes_served": 4096,
1030 "request_at": "2026-05-22T10:00:00Z",
1031 "ingest_at": null,
1032 "gcs_at": null,
1033 "verdict_at": "2026-05-22T10:00:01Z",
1034 "policy_eval_at": "2026-05-22T10:00:02Z",
1035 "response_at": "2026-05-22T10:00:03Z",
1036 "app_version": "1.2.3"
1037 }],
1038 "record_count": 1,
1039 "per_route": {"/v1/customer/verdicts/npm": 1},
1040 "backend_status": "wired"
1041 }"#;
1042 let resp: AuditResponse = serde_json::from_str(json).unwrap();
1043 assert_eq!(resp.records.len(), 1);
1044 assert_eq!(resp.record_count, 1);
1045 assert_eq!(resp.backend_status, "wired");
1046 assert_eq!(resp.window.since.as_deref(), Some("2026-05-22T00:00:00Z"));
1047 assert_eq!(resp.per_route.get("/v1/customer/verdicts/npm"), Some(&1));
1048
1049 let e = &resp.records[0];
1050 // Every renamed field must carry a value — the pre-fix struct would
1051 // have left these empty because the JSON keys did not match.
1052 assert_eq!(e.request_id, "01936b8f-3c4a-7a12-9c00-000000000001");
1053 assert_eq!(e.correlation_id, "corr-1");
1054 assert_eq!(e.ecosystem, "npm");
1055 assert_eq!(e.package_name, "lodash");
1056 assert_eq!(e.package_version, "4.17.21");
1057 assert_eq!(e.variant, "default");
1058 assert_eq!(e.policy_decision, "ALLOW");
1059 assert_eq!(e.verdict_id, "01936b8f-3c4a-7a12-9c00-0000000000aa");
1060 assert_eq!(e.verdict_source, "ALLOWED_NO_FINDINGS");
1061 assert_eq!(e.policy_rule_id_matched, "rule-42");
1062 assert_eq!(e.risk_acceptance_status, "NONE");
1063 assert_eq!(e.reasoning, "ok");
1064 assert!(e.gcs_hit);
1065 assert_eq!(e.request_at, "2026-05-22T10:00:00Z");
1066 assert_eq!(e.verdict_at, "2026-05-22T10:00:01Z");
1067 assert_eq!(e.response_at, "2026-05-22T10:00:03Z");
1068 assert_eq!(e.app_version, "1.2.3");
1069 }
1070
1071 /// Backend-not-wired path: App emits the honesty signal + empty records.
1072 /// The CLI must decode `backend_status` (not silently coerce to empty
1073 /// via a `next_cursor` field that never existed on the wire).
1074 #[test]
1075 fn empty_audit_response_carries_backend_status() {
1076 let json = r#"{
1077 "window": {"since": null, "until": null},
1078 "records": [],
1079 "record_count": 0,
1080 "per_route": {},
1081 "backend_status": "not_wired"
1082 }"#;
1083 let resp: AuditResponse = serde_json::from_str(json).unwrap();
1084 assert!(resp.records.is_empty());
1085 assert_eq!(resp.record_count, 0);
1086 assert_eq!(resp.backend_status, "not_wired");
1087 assert!(resp.window.since.is_none());
1088 assert!(resp.window.until.is_none());
1089 }
1090
1091 #[test]
1092 fn policy_preview_request_omits_none_policy() {
1093 let req = PolicyPreviewRequest {
1094 packages: vec![PackageRef {
1095 ecosystem: "npm".to_string(),
1096 name: "lodash".to_string(),
1097 version: "4.17.21".to_string(),
1098 }],
1099 policy: None,
1100 };
1101 let json = serde_json::to_string(&req).unwrap();
1102 // None policy should not appear in serialized output
1103 assert!(!json.contains("policy"));
1104 assert!(json.contains("lodash"));
1105 }
1106
1107 #[test]
1108 fn policy_preview_request_emits_policy_when_some() {
1109 let req = PolicyPreviewRequest {
1110 packages: vec![],
1111 policy: Some(serde_json::json!({"rules": []})),
1112 };
1113 let json = serde_json::to_string(&req).unwrap();
1114 assert!(json.contains("\"policy\""));
1115 assert!(json.contains("\"rules\""));
1116 }
1117
1118 #[test]
1119 fn round_trips_via_json() {
1120 let v = Verdict {
1121 verdict_id: "01JBYK002".to_string(),
1122 verdict: "INSUFFICIENT_DATA".to_string(),
1123 source: "INSUFFICIENT_DATA".to_string(),
1124 stale_since_at: Some("2026-04-21T00:00:00Z".to_string()),
1125 staleness_reason: Some("upstream silent >30d".to_string()),
1126 ..Default::default()
1127 };
1128 let s = serde_json::to_string(&v).unwrap();
1129 let parsed: Verdict = serde_json::from_str(&s).unwrap();
1130 assert_eq!(parsed.verdict_id, "01JBYK002");
1131 assert_eq!(parsed.stale_since_at.as_deref(), Some("2026-04-21T00:00:00Z"));
1132 }
1133
1134 #[test]
1135 fn cleanlib_601_preserves_rich_data_recommended_version() {
1136 // The App's rich_data.recommended_version must survive deserialization into
1137 // the typed field (was silently dropped — no field for it). Extra rich_data
1138 // keys are tolerated (no deny_unknown_fields).
1139 let json = r#"{
1140 "verdict_id": "01JBYK601",
1141 "verdict": "VECTOR_VERDICT",
1142 "source": "VECTOR_VERDICT",
1143 "rich_data": { "recommended_version": "4.17.21", "some_other_key": 7 }
1144 }"#;
1145 let v: Verdict = serde_json::from_str(json).unwrap();
1146 assert_eq!(
1147 v.rich_data
1148 .as_ref()
1149 .and_then(|r| r.recommended_version.as_deref()),
1150 Some("4.17.21")
1151 );
1152
1153 // Absent rich_data → None (v1 back-compat, struct-level serde default).
1154 let v1: Verdict =
1155 serde_json::from_str(r#"{"verdict_id":"x","verdict":"ALLOWED_NO_FINDINGS","source":"x"}"#)
1156 .unwrap();
1157 assert!(v1.rich_data.is_none());
1158 }
1159
1160 // ─── Lane-2 M1 — severity + decision carrying ──────────────────────
1161
1162 #[test]
1163 fn verdict_round_trips_severity_and_decision() {
1164 let v = Verdict {
1165 verdict_id: "01JM1S001".to_string(),
1166 verdict: "VECTOR_VERDICT".to_string(),
1167 source: "VECTOR_VERDICT".to_string(),
1168 severity: Some("HIGH".to_string()),
1169 decision: Some("DENY".to_string()),
1170 ..Default::default()
1171 };
1172 let s = serde_json::to_string(&v).unwrap();
1173 let parsed: Verdict = serde_json::from_str(&s).unwrap();
1174 assert_eq!(parsed.severity.as_deref(), Some("HIGH"));
1175 assert_eq!(parsed.decision.as_deref(), Some("DENY"));
1176 }
1177
1178 #[test]
1179 fn verdict_tolerates_missing_severity_and_decision() {
1180 // Pre-M1 payload shape — no severity/decision fields. Must still parse
1181 // via serde-default tolerance per the struct's `#[serde(default)]`.
1182 let pre_m1_json = r#"{
1183 "verdict_id": "01JM1S002",
1184 "verdict": "ALLOWED_NO_FINDINGS",
1185 "source": "ALLOWED_NO_FINDINGS",
1186 "confidence": 0.95,
1187 "composite_score": 8,
1188 "reasoning": "",
1189 "similar_to": [],
1190 "evidence_gaps": [],
1191 "suggested_actions": []
1192 }"#;
1193 let v: Verdict = serde_json::from_str(pre_m1_json).expect("pre-M1 shape must still parse");
1194 assert!(v.severity.is_none());
1195 assert!(v.decision.is_none());
1196 }
1197
1198 #[test]
1199 fn verdict_decision_canonical_values_match_js_py_go() {
1200 // Lane-2 M1 acceptance: decision values match js/py/go SDK envelope.
1201 // Schema-locked set: ALLOW | WARN | DENY | RISK_ACCEPTANCE_REQUIRED.
1202 for d in ["ALLOW", "WARN", "DENY", "RISK_ACCEPTANCE_REQUIRED"] {
1203 let v = Verdict {
1204 decision: Some(d.to_string()),
1205 ..Default::default()
1206 };
1207 let s = serde_json::to_string(&v).unwrap();
1208 assert!(s.contains(&format!("\"decision\":\"{}\"", d)));
1209 }
1210 }
1211
1212 #[test]
1213 fn verdict_severity_canonical_values_match_cleanlib_core() {
1214 // Sister of `cleanlib-core::Severity` enum: NONE | LOW | MEDIUM | HIGH | CRITICAL.
1215 for sev in ["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"] {
1216 let v = Verdict {
1217 severity: Some(sev.to_string()),
1218 ..Default::default()
1219 };
1220 let s = serde_json::to_string(&v).unwrap();
1221 assert!(s.contains(&format!("\"severity\":\"{}\"", sev)));
1222 }
1223 }
1224
1225 // ─── CLEANLIB-794 · AuditEntry.request_id nil-UUID normalizer ─────────
1226
1227 /// Counterexample: the App-side placeholder must NOT reach the SDK
1228 /// caller. Every request_id equal to the nil-UUID string (App emits
1229 /// `Uuid::default().to_string()` when the persisted record carries no
1230 /// real id) is folded to the empty string on deserialize.
1231 ///
1232 /// FAILS on the pre-fix build: the pre-fix struct field was plain
1233 /// `pub request_id: String` with no deserializer, so this assertion
1234 /// would see the literal nil-UUID string. PASSES post-fix.
1235 #[test]
1236 fn cleanlib_794_audit_entry_nil_uuid_request_id_normalizes_to_empty() {
1237 let json = r#"{"request_id": "00000000-0000-0000-0000-000000000000",
1238 "ecosystem": "npm",
1239 "package_name": "lodash",
1240 "package_version": "4.17.21",
1241 "policy_decision": "ALLOW"}"#;
1242 let e: AuditEntry = serde_json::from_str(json).unwrap();
1243 assert_eq!(
1244 e.request_id, "",
1245 "nil-UUID must fold to empty string; pre-fix would surface the fake id verbatim"
1246 );
1247 // Sibling fields must NOT be touched — the normalizer scoped to
1248 // request_id only. [SibFields] guard.
1249 assert_eq!(e.package_name, "lodash");
1250 assert_eq!(e.ecosystem, "npm");
1251 assert_eq!(e.policy_decision, "ALLOW");
1252 }
1253
1254 /// A real ULID / UUID must pass through unchanged — the normalizer must
1255 /// not accidentally reject any non-nil value.
1256 #[test]
1257 fn cleanlib_794_audit_entry_real_request_id_passes_through() {
1258 let json = r#"{"request_id": "01936b8f-3c4a-7a12-9c00-000000000001",
1259 "ecosystem": "npm",
1260 "package_name": "lodash"}"#;
1261 let e: AuditEntry = serde_json::from_str(json).unwrap();
1262 assert_eq!(e.request_id, "01936b8f-3c4a-7a12-9c00-000000000001");
1263 }
1264
1265 /// A missing `request_id` key (App may transition to omitting the field
1266 /// once it stops stamping the placeholder) deserializes as empty — same
1267 /// visible outcome as the nil-UUID fold, so callers see one honest
1268 /// "absent" signal regardless of how the App represents it on the wire.
1269 #[test]
1270 fn cleanlib_794_audit_entry_missing_request_id_is_empty() {
1271 let json = r#"{"ecosystem": "npm", "package_name": "lodash"}"#;
1272 let e: AuditEntry = serde_json::from_str(json).unwrap();
1273 assert_eq!(e.request_id, "");
1274 }
1275
1276 /// Empty string in the wire is already an honest "absent" — must pass
1277 /// through unchanged. This locks the third representation of "no id"
1278 /// alongside the fold-to-empty and the missing-key branches.
1279 #[test]
1280 fn cleanlib_794_audit_entry_empty_request_id_stays_empty() {
1281 let json = r#"{"request_id": "", "ecosystem": "npm", "package_name": "lodash"}"#;
1282 let e: AuditEntry = serde_json::from_str(json).unwrap();
1283 assert_eq!(e.request_id, "");
1284 }
1285}