Skip to main content

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}
218
219/// CLEANLIB-613: the App's `top_findings` block on a customer verdict — the
220/// per-CVE findings that back a VECTOR_VERDICT/DENY, plus the KEV / ransomware
221/// flags. Modeled so the CLI `fix` command can reach each finding's structured
222/// `fixed_version` (the App's always-populated remediation target). Tolerant:
223/// `#[serde(default)]`, all-optional, no `deny_unknown_fields`.
224#[derive(Debug, Clone, Deserialize, Serialize, Default)]
225#[serde(default)]
226pub struct TopFindings {
227    /// Total CVE count backing this verdict (may exceed `findings.len()` when the
228    /// App truncates to the top-N; the summary still counts them all).
229    pub cve_count: Option<u32>,
230    pub on_kev: Option<bool>,
231    pub on_ransomware: Option<bool>,
232    /// The per-CVE findings. Each carries its own `fixed_version`; the CLI `fix`
233    /// command takes the max across these as the cumulative upgrade target.
234    pub findings: Vec<Finding>,
235}
236
237/// CLEANLIB-613: one CVE finding inside [`TopFindings`]. Only the fields the CLI
238/// consumes are typed; the App may add more (tolerated — no `deny_unknown_fields`).
239#[derive(Debug, Clone, Deserialize, Serialize, Default)]
240#[serde(default)]
241pub struct Finding {
242    pub cve_id: Option<String>,
243    /// The version that remediates THIS CVE. `fix` takes the max of these across
244    /// findings as the cumulative upgrade target (you must reach at least the
245    /// highest per-CVE fix to clear every CVE). `None` when no fix exists yet.
246    pub fixed_version: Option<String>,
247    pub vulnerable_versions: Option<String>,
248    pub severity: Option<String>,
249    pub cvss_v3_score: Option<f64>,
250}
251
252/// CLEANLIB-601: typed subset of the App's `rich_data` block. Carries the
253/// `recommended_version` upgrade target for the CLI `fix` command. `#[serde(default)]`
254/// + no `deny_unknown_fields` — other `rich_data` keys (evidence/composition, which
255/// the App also hoists to top-level passthrough fields above) are tolerated, and a
256/// `rich_data` object missing `recommended_version` deserializes to `None`.
257#[derive(Debug, Clone, Deserialize, Serialize, Default)]
258#[serde(default)]
259pub struct RichData {
260    /// The App's suggested upgrade target (e.g. `"4.17.21"`). `None` when the
261    /// App emits no recommendation for this coordinate.
262    pub recommended_version: Option<String>,
263}
264
265/// Cycle-15 honesty signal block on the SDK Verdict shape. Mirrors the App
266/// wire-shape `cleanlib_core::AvailabilityBlock`. `Option<bool>`-style
267/// passthrough for `degraded_stale` so pre-cycle-15 payloads (without the
268/// block) deserialize cleanly.
269///
270/// CLEANLIB-105 App-3.2 M1/M2 additions: `kev` / `epss` /
271/// `exploitation_fusion` sub-fields as `Option<String>` (SDK-passthrough
272/// per §5 ripple discipline). String tags: `"available"` |
273/// `"not_applicable"` | `"unavailable"` | `"degraded_stale"` per
274/// `cleanlib_core::FieldAvailability` snake_case serde. `Option` on the
275/// SDK side (vs `FieldAvailability` non-Optional on the App side) lets
276/// pre-M1 payloads without any sub-field key deserialize cleanly to
277/// `None` — the SDK's `derive_status.rs` treats `None` and
278/// `"unavailable"` identically (both fail the "== Some(\"available\")"
279/// check on lines 76+).
280///
281/// NOTE: no `skip_serializing_if` on any field — this struct is bincode-
282/// serialized (positionally) by `cleanlib-cli::PersistentCache`, and
283/// conditional omission would corrupt the cache alignment (§CLEANLIB-104
284/// design doc §3.M3 cache-shape note). The parent `Verdict` documents this
285/// invariant at the `previous_verdict` field. Fields that need to be omitted
286/// from the customer-facing JSON envelope are re-shaped by
287/// [`crate::verdict_to_envelope::verdict_to_envelope_v1`] (which is the
288/// customer wire path), not by field-level serde attributes here.
289#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
290#[serde(default)]
291pub struct AvailabilityBlock {
292    pub degraded_stale: bool,
293    /// CISA KEV substrate availability tag
294    /// (`"available"` | `"not_applicable"` | `"unavailable"` | `"degraded_stale"`).
295    pub kev: Option<String>,
296    /// FIRST.org EPSS substrate availability tag.
297    pub epss: Option<String>,
298    /// Composite exploitation-likelihood availability tag.
299    pub exploitation_fusion: Option<String>,
300}
301
302/// Prior-verdict comparison. Surfaces when the CleanLibrary App has a
303/// stored prior verdict for the same `(ecosystem, package, version)` that
304/// differs from the current one — useful for AI agents and dashboards
305/// that want to flag verdict-state changes since the last fetch.
306/// Sister-shape with `cleanlib_sdk_go::PreviousVerdict` and
307/// `cleanlib-core::PreviousVerdict` in the App.
308#[derive(Debug, Clone, Default, Deserialize, Serialize)]
309#[serde(default)]
310pub struct PreviousVerdict {
311    pub verdict_id: String,
312    pub verdict: String,
313    pub computed_at: String,
314    pub diff: String,
315}
316
317impl Default for Verdict {
318    fn default() -> Self {
319        Self {
320            verdict_id: String::new(),
321            verdict: String::new(),
322            source: String::new(),
323            confidence: 0.0,
324            composite_score: 0,
325            reasoning: String::new(),
326            similar_to: Vec::new(),
327            evidence_gaps: Vec::new(),
328            suggested_actions: Vec::new(),
329            data_freshness_at: None,
330            data_oldest_signal_at: None,
331            stale_since_at: None,
332            staleness_reason: None,
333            computed_at: None,
334            severity: None,
335            decision: None,
336            previous_verdict: None,
337            availability: AvailabilityBlock::default(),
338            // CLEANLIB-412 envelope-v2 (Step-6 phase-a) — absent on v1.
339            envelope_version: None,
340            customer_state: None,
341            state: None,
342            source_state: None,
343            policy_version: None,
344            audit_record_id: None,
345            audit_record_hash: None,
346            attestation: None,
347            // CLEANLIB-518 §4 tolerant-passthrough — absent unless the App
348            // enhanced-verdict flag is on.
349            evidence: None,
350            composition: None,
351            // CLEANLIB-601 — absent unless the App emits a rich_data block.
352            rich_data: None,
353            // CLEANLIB-613 — absent on v1; populated on customer-verdict responses.
354            top_findings: None,
355        }
356    }
357}
358
359/// One package identity for policy-preview / scan requests.
360///
361/// Wire-contract note: the App-side coordinate struct
362/// (`cleanlib-app::verbs::PackageRef`, shared by `POST /v1/scan` +
363/// `POST /v1/policy/preview`) names this field `package`, not `name`.
364/// Serializing the Rust identifier `name` verbatim made the App reject the
365/// body with `422 … packages[0]: missing field \`package\``, breaking both
366/// `cleanlib scan` and `cleanlib policy preview`. The `#[serde(rename)]` puts
367/// `package` on the wire while keeping the `name` identifier that the
368/// packages-file parsers in `commands::scan` already construct.
369#[derive(Debug, Clone, Deserialize, Serialize)]
370pub struct PackageRef {
371    pub ecosystem: String,
372    #[serde(rename = "package")]
373    pub name: String,
374    pub version: String,
375}
376
377/// Body of `POST /v1/policy/preview` — packages + optional
378/// hypothetical policy override (JSON-shaped; YAML-source customers
379/// convert client-side).
380#[derive(Debug, Clone, Serialize)]
381pub struct PolicyPreviewRequest {
382    pub packages: Vec<PackageRef>,
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub policy: Option<serde_json::Value>,
385}
386
387/// Per-package decision returned from `/v1/policy/preview` or
388/// embedded in audit entries.
389#[derive(Debug, Clone, Deserialize, Serialize, Default)]
390#[serde(default)]
391pub struct PolicyDecision {
392    pub ecosystem: String,
393    pub package: String,
394    pub version: String,
395    /// `ALLOW` | `DENY` | `WARN` | `INSUFFICIENT_DATA` | `RISK_ACCEPTANCE_REQUIRED`
396    pub decision: String,
397    pub reason: String,
398    pub verdict_id: Option<String>,
399    pub policy_rule_id: Option<String>,
400}
401
402/// Response from `POST /v1/policy/preview`.
403#[derive(Debug, Clone, Deserialize, Serialize, Default)]
404#[serde(default)]
405pub struct PolicyPreviewResponse {
406    pub decisions: Vec<PolicyDecision>,
407}
408
409/// Body of `POST /v1/scan` — a batch of package coordinates, no policy.
410///
411/// Distinct from [`PolicyPreviewRequest`]: `cleanlib scan` previews packages
412/// against the customer's *active* policy (verdict-driven, server-side), so it
413/// carries no `policy_yaml`. Routing `scan` through `/v1/policy/preview`
414/// (which requires `policy_yaml`) was the 422 that hid behind the earlier
415/// `package`-field fix.
416#[derive(Debug, Clone, Serialize)]
417pub struct ScanRequest {
418    pub packages: Vec<PackageRef>,
419}
420
421/// One entry of the `POST /v1/scan` response. Mirrors the App's
422/// `verbs::ScanResult` wire shape: the package coordinate is flattened
423/// (`ecosystem` / `package` / `version`) alongside an optional `verdict`
424/// (present on success) or `error` string (per-package partial failure —
425/// the App resolves each package independently and never fails the whole
426/// batch on one miss).
427#[derive(Debug, Clone, Deserialize, Serialize, Default)]
428#[serde(default)]
429pub struct ScanResult {
430    pub ecosystem: String,
431    pub package: String,
432    pub version: String,
433    pub verdict: Option<Verdict>,
434    pub error: Option<String>,
435}
436
437/// Response from `POST /v1/scan`. One [`ScanResult`] per requested package.
438#[derive(Debug, Clone, Deserialize, Serialize, Default)]
439#[serde(default)]
440pub struct ScanResponse {
441    pub results: Vec<ScanResult>,
442}
443
444/// One audit log entry returned from `GET /v1/audit`.
445///
446/// **CLEANLIB-366 — App wire is source of truth.** Mirrors the App-side
447/// `AuditRow` (cleanlib-audit-clickhouse) which the App serializes into each
448/// element of the `records` array. Prior CLI struct silently dropped fields
449/// because names had drifted (`package` vs App's `package_name`, `version` vs
450/// `package_version`, `decision` vs `policy_decision`, `reason` vs
451/// `reasoning`, `at` vs `request_at`) — with `#[serde(default)]` deserialize
452/// succeeded and every field came back empty. Same class as CLEANLIB-348.
453///
454/// Field names below match `AuditRow` exactly. All fields default-tolerant
455/// via struct-level `#[serde(default)]` so partial responses or App-side
456/// schema evolution do not force a CLI recompile.
457///
458/// UUID fields on the App side (`request_id`, `verdict_id`) serialize as
459/// hyphenated strings; datetime fields (`request_at`, `verdict_at`,
460/// `response_at`, …) serialize as RFC 3339 strings — hence `String` here.
461#[derive(Debug, Clone, Deserialize, Serialize, Default)]
462#[serde(default)]
463pub struct AuditEntry {
464    // request identification
465    pub request_id: String,
466    pub correlation_id: String,
467
468    // request shape
469    pub ecosystem: String,
470    pub package_name: String,
471    pub package_version: String,
472    pub variant: String,
473
474    // decision
475    pub policy_decision: String,
476    pub verdict_id: String,
477    pub verdict_source: String,
478    pub policy_rule_id_matched: String,
479    pub risk_acceptance_status: String,
480    pub reasoning: String,
481
482    // catalog
483    pub gcs_hit: bool,
484
485    // timing (RFC 3339 strings)
486    pub request_at: String,
487    pub verdict_at: String,
488    pub response_at: String,
489
490    // metadata
491    pub app_version: String,
492}
493
494/// Query-window echo returned inside [`AuditResponse::window`]. Mirrors the
495/// App-side `AuditWindow` — echoes the caller's `since` / `until` filter
496/// values verbatim (or `None` when the filter was omitted).
497#[derive(Debug, Clone, Deserialize, Serialize, Default)]
498#[serde(default)]
499pub struct AuditWindow {
500    pub since: Option<String>,
501    pub until: Option<String>,
502}
503
504/// Response from `GET /v1/audit`. Mirrors the App-side `AuditResponse` in
505/// `cleanlib-app::verbs`. See [`AuditEntry`] for the CLEANLIB-366 field-name
506/// alignment note.
507///
508/// `backend_status` is `"wired"` when the App has an `AuditReader` attached
509/// and the read succeeded, `"not_wired"` when no reader is configured, or
510/// `"read_error"` when the reader errored. CLI callers surface this signal
511/// so customers can distinguish "empty because no rows" from "empty because
512/// the audit backend is offline".
513#[derive(Debug, Clone, Deserialize, Serialize, Default)]
514#[serde(default)]
515pub struct AuditResponse {
516    pub window: AuditWindow,
517    pub records: Vec<AuditEntry>,
518    pub record_count: usize,
519    pub per_route: std::collections::BTreeMap<String, usize>,
520    pub backend_status: String,
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn parses_minimal_verdict() {
529        let json = r#"{
530            "verdict_id": "01JBYK000",
531            "verdict": "ALLOWED_NO_FINDINGS",
532            "source": "ALLOWED_NO_FINDINGS"
533        }"#;
534        let v: Verdict = serde_json::from_str(json).unwrap();
535        assert_eq!(v.verdict_id, "01JBYK000");
536        assert_eq!(v.verdict, "ALLOWED_NO_FINDINGS");
537        assert_eq!(v.confidence, 0.0);
538        assert!(v.similar_to.is_empty());
539    }
540
541    /// CLEANLIB-468 regression: the live envelope-v2 Path-A wire — where the
542    /// top-level `verdict` key is a nested OBJECT and the label rides
543    /// `verdict_label` — must deserialize WITHOUT the `invalid type: map,
544    /// expected a string` error that broke Ajeet-Yadav's CLI (CLEANLIB-462).
545    #[test]
546    fn parses_envelope_v2_path_a_wire_with_verdict_object_and_label() {
547        let json = r#"{
548            "verdict_id": "01JBYK042",
549            "verdict": { "type": "VECTOR_VERDICT", "status": "WARN", "customer_state": "vulnerable" },
550            "verdict_label": "VECTOR_VERDICT",
551            "source": "CVE_FINDING",
552            "envelope_version": 2,
553            "customer_state": "vulnerable",
554            "state": "WARN",
555            "source_state": "CVE_FINDING"
556        }"#;
557        // Must NOT error (the nested `verdict` object is skipped; label read from
558        // `verdict_label`).
559        let v: Verdict = serde_json::from_str(json)
560            .expect("Path-A v2 wire must deserialize — CLEANLIB-468");
561        assert_eq!(v.verdict, "VECTOR_VERDICT");
562        assert_eq!(v.source, "CVE_FINDING");
563        assert_eq!(v.customer_state.as_deref(), Some("vulnerable"));
564        assert_eq!(v.source_state.as_deref(), Some("CVE_FINDING"));
565    }
566
567    #[test]
568    fn parses_full_verdict() {
569        let json = r#"{
570            "verdict_id": "01JBYK001",
571            "verdict": "VECTOR_VERDICT",
572            "source": "VECTOR_VERDICT",
573            "confidence": 0.98,
574            "composite_score": 92,
575            "reasoning": "Confirmed malware",
576            "similar_to": ["01JBYK999"],
577            "evidence_gaps": [],
578            "suggested_actions": ["DENY across customers"],
579            "data_freshness_at": "2026-05-21T10:00:00Z",
580            "computed_at": "2026-05-21T10:01:00Z"
581        }"#;
582        let v: Verdict = serde_json::from_str(json).unwrap();
583        assert_eq!(v.composite_score, 92);
584        assert_eq!(v.confidence, 0.98);
585        assert_eq!(v.similar_to.len(), 1);
586        assert_eq!(v.suggested_actions[0], "DENY across customers");
587    }
588
589    #[test]
590    fn cleanlib_613_parses_top_findings_fixed_version() {
591        // Locks the wire contract for the CLI `fix` remediation source. Captured
592        // from cleanapp.clnstrt.dev/v1/customer/verdicts/npm/lodash/4.17.20
593        // (2026-08-20): `verdict` is a nested object, `rich_data` is null, and the
594        // remediation target lives in `top_findings.findings[].fixed_version`.
595        let json = r#"{
596            "verdict_id": "01JBYK613",
597            "verdict": {"customer_state":"vulnerable","status":"WARN","type":"VECTOR_VERDICT"},
598            "source": "CVE_FINDING",
599            "rich_data": null,
600            "suggested_actions": ["Upgrade to 4.18.0+ to address CVE-2026-4800 (HIGH, CVSS 8.1)"],
601            "top_findings": {
602                "cve_count": 6,
603                "on_kev": false,
604                "on_ransomware": false,
605                "findings": [
606                    {"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},
607                    {"cve_id":"CVE-2021-23337","fixed_version":"4.17.21","severity":"HIGH"}
608                ]
609            }
610        }"#;
611        let v: Verdict = serde_json::from_str(json).unwrap();
612        // The nested-object `verdict` still deserializes to its `type` (de_verdict_label).
613        assert_eq!(v.verdict, "VECTOR_VERDICT");
614        assert!(v.rich_data.is_none());
615        let tf = v.top_findings.expect("top_findings must parse");
616        assert_eq!(tf.cve_count, Some(6));
617        assert_eq!(tf.findings.len(), 2);
618        assert_eq!(tf.findings[0].fixed_version.as_deref(), Some("4.18.0"));
619        assert_eq!(tf.findings[0].cve_id.as_deref(), Some("CVE-2026-4800"));
620        // A finding omitting optional fields still parses (serde default tolerance).
621        assert_eq!(tf.findings[1].fixed_version.as_deref(), Some("4.17.21"));
622        assert!(tf.findings[1].cvss_v3_score.is_none());
623    }
624
625    #[test]
626    fn parses_policy_preview_response() {
627        let json = r#"{
628            "decisions": [
629                {"ecosystem":"npm","package":"left-pad","version":"1.3.0","decision":"ALLOW","reason":"ok"},
630                {"ecosystem":"npm","package":"event-stream","version":"3.3.6","decision":"DENY","reason":"malware","verdict_id":"01JBYK999"}
631            ]
632        }"#;
633        let resp: PolicyPreviewResponse = serde_json::from_str(json).unwrap();
634        assert_eq!(resp.decisions.len(), 2);
635        assert_eq!(resp.decisions[0].decision, "ALLOW");
636        assert_eq!(resp.decisions[1].decision, "DENY");
637        assert_eq!(resp.decisions[1].verdict_id.as_deref(), Some("01JBYK999"));
638    }
639
640    /// CLEANLIB-366 — deserialize against the App's real wire shape
641    /// (`{window, records, record_count, per_route, backend_status}`) and
642    /// assert every renamed field (`package_name`, `package_version`,
643    /// `policy_decision`, `reasoning`, `request_at`) round-trips a non-empty
644    /// value. The pre-fix struct used `entries` + `{package, version,
645    /// decision, reason, at}` and silently dropped every field on this
646    /// payload because names did not match.
647    #[test]
648    fn parses_audit_response_matches_app_wire_shape() {
649        let json = r#"{
650            "window": {"since": "2026-05-22T00:00:00Z", "until": "2026-05-23T00:00:00Z"},
651            "records": [{
652                "request_id": "01936b8f-3c4a-7a12-9c00-000000000001",
653                "correlation_id": "corr-1",
654                "customer_ip_hashed": "sha256:aaaa",
655                "ecosystem": "npm",
656                "package_name": "lodash",
657                "package_version": "4.17.21",
658                "variant": "default",
659                "user_agent": "cleanlib-cli/0.1.4",
660                "policy_decision": "ALLOW",
661                "verdict_id": "01936b8f-3c4a-7a12-9c00-0000000000aa",
662                "verdict_source": "ALLOWED_NO_FINDINGS",
663                "policy_rule_id_matched": "rule-42",
664                "risk_acceptance_status": "NONE",
665                "reasoning": "ok",
666                "gcs_hit": true,
667                "gcs_object_path": "gs://bucket/obj",
668                "bytes_served": 4096,
669                "request_at": "2026-05-22T10:00:00Z",
670                "ingest_at": null,
671                "gcs_at": null,
672                "verdict_at": "2026-05-22T10:00:01Z",
673                "policy_eval_at": "2026-05-22T10:00:02Z",
674                "response_at": "2026-05-22T10:00:03Z",
675                "app_version": "1.2.3"
676            }],
677            "record_count": 1,
678            "per_route": {"/v1/customer/verdicts/npm": 1},
679            "backend_status": "wired"
680        }"#;
681        let resp: AuditResponse = serde_json::from_str(json).unwrap();
682        assert_eq!(resp.records.len(), 1);
683        assert_eq!(resp.record_count, 1);
684        assert_eq!(resp.backend_status, "wired");
685        assert_eq!(resp.window.since.as_deref(), Some("2026-05-22T00:00:00Z"));
686        assert_eq!(resp.per_route.get("/v1/customer/verdicts/npm"), Some(&1));
687
688        let e = &resp.records[0];
689        // Every renamed field must carry a value — the pre-fix struct would
690        // have left these empty because the JSON keys did not match.
691        assert_eq!(e.request_id, "01936b8f-3c4a-7a12-9c00-000000000001");
692        assert_eq!(e.correlation_id, "corr-1");
693        assert_eq!(e.ecosystem, "npm");
694        assert_eq!(e.package_name, "lodash");
695        assert_eq!(e.package_version, "4.17.21");
696        assert_eq!(e.variant, "default");
697        assert_eq!(e.policy_decision, "ALLOW");
698        assert_eq!(e.verdict_id, "01936b8f-3c4a-7a12-9c00-0000000000aa");
699        assert_eq!(e.verdict_source, "ALLOWED_NO_FINDINGS");
700        assert_eq!(e.policy_rule_id_matched, "rule-42");
701        assert_eq!(e.risk_acceptance_status, "NONE");
702        assert_eq!(e.reasoning, "ok");
703        assert!(e.gcs_hit);
704        assert_eq!(e.request_at, "2026-05-22T10:00:00Z");
705        assert_eq!(e.verdict_at, "2026-05-22T10:00:01Z");
706        assert_eq!(e.response_at, "2026-05-22T10:00:03Z");
707        assert_eq!(e.app_version, "1.2.3");
708    }
709
710    /// Backend-not-wired path: App emits the honesty signal + empty records.
711    /// The CLI must decode `backend_status` (not silently coerce to empty
712    /// via a `next_cursor` field that never existed on the wire).
713    #[test]
714    fn empty_audit_response_carries_backend_status() {
715        let json = r#"{
716            "window": {"since": null, "until": null},
717            "records": [],
718            "record_count": 0,
719            "per_route": {},
720            "backend_status": "not_wired"
721        }"#;
722        let resp: AuditResponse = serde_json::from_str(json).unwrap();
723        assert!(resp.records.is_empty());
724        assert_eq!(resp.record_count, 0);
725        assert_eq!(resp.backend_status, "not_wired");
726        assert!(resp.window.since.is_none());
727        assert!(resp.window.until.is_none());
728    }
729
730    #[test]
731    fn policy_preview_request_omits_none_policy() {
732        let req = PolicyPreviewRequest {
733            packages: vec![PackageRef {
734                ecosystem: "npm".to_string(),
735                name: "lodash".to_string(),
736                version: "4.17.21".to_string(),
737            }],
738            policy: None,
739        };
740        let json = serde_json::to_string(&req).unwrap();
741        // None policy should not appear in serialized output
742        assert!(!json.contains("policy"));
743        assert!(json.contains("lodash"));
744    }
745
746    #[test]
747    fn policy_preview_request_emits_policy_when_some() {
748        let req = PolicyPreviewRequest {
749            packages: vec![],
750            policy: Some(serde_json::json!({"rules": []})),
751        };
752        let json = serde_json::to_string(&req).unwrap();
753        assert!(json.contains("\"policy\""));
754        assert!(json.contains("\"rules\""));
755    }
756
757    #[test]
758    fn round_trips_via_json() {
759        let v = Verdict {
760            verdict_id: "01JBYK002".to_string(),
761            verdict: "INSUFFICIENT_DATA".to_string(),
762            source: "INSUFFICIENT_DATA".to_string(),
763            stale_since_at: Some("2026-04-21T00:00:00Z".to_string()),
764            staleness_reason: Some("upstream silent >30d".to_string()),
765            ..Default::default()
766        };
767        let s = serde_json::to_string(&v).unwrap();
768        let parsed: Verdict = serde_json::from_str(&s).unwrap();
769        assert_eq!(parsed.verdict_id, "01JBYK002");
770        assert_eq!(parsed.stale_since_at.as_deref(), Some("2026-04-21T00:00:00Z"));
771    }
772
773    #[test]
774    fn cleanlib_601_preserves_rich_data_recommended_version() {
775        // The App's rich_data.recommended_version must survive deserialization into
776        // the typed field (was silently dropped — no field for it). Extra rich_data
777        // keys are tolerated (no deny_unknown_fields).
778        let json = r#"{
779            "verdict_id": "01JBYK601",
780            "verdict": "VECTOR_VERDICT",
781            "source": "VECTOR_VERDICT",
782            "rich_data": { "recommended_version": "4.17.21", "some_other_key": 7 }
783        }"#;
784        let v: Verdict = serde_json::from_str(json).unwrap();
785        assert_eq!(
786            v.rich_data
787                .as_ref()
788                .and_then(|r| r.recommended_version.as_deref()),
789            Some("4.17.21")
790        );
791
792        // Absent rich_data → None (v1 back-compat, struct-level serde default).
793        let v1: Verdict =
794            serde_json::from_str(r#"{"verdict_id":"x","verdict":"ALLOWED_NO_FINDINGS","source":"x"}"#)
795                .unwrap();
796        assert!(v1.rich_data.is_none());
797    }
798
799    // ─── Lane-2 M1 — severity + decision carrying ──────────────────────
800
801    #[test]
802    fn verdict_round_trips_severity_and_decision() {
803        let v = Verdict {
804            verdict_id: "01JM1S001".to_string(),
805            verdict: "VECTOR_VERDICT".to_string(),
806            source: "VECTOR_VERDICT".to_string(),
807            severity: Some("HIGH".to_string()),
808            decision: Some("DENY".to_string()),
809            ..Default::default()
810        };
811        let s = serde_json::to_string(&v).unwrap();
812        let parsed: Verdict = serde_json::from_str(&s).unwrap();
813        assert_eq!(parsed.severity.as_deref(), Some("HIGH"));
814        assert_eq!(parsed.decision.as_deref(), Some("DENY"));
815    }
816
817    #[test]
818    fn verdict_tolerates_missing_severity_and_decision() {
819        // Pre-M1 payload shape — no severity/decision fields. Must still parse
820        // via serde-default tolerance per the struct's `#[serde(default)]`.
821        let pre_m1_json = r#"{
822            "verdict_id": "01JM1S002",
823            "verdict": "ALLOWED_NO_FINDINGS",
824            "source": "ALLOWED_NO_FINDINGS",
825            "confidence": 0.95,
826            "composite_score": 8,
827            "reasoning": "",
828            "similar_to": [],
829            "evidence_gaps": [],
830            "suggested_actions": []
831        }"#;
832        let v: Verdict = serde_json::from_str(pre_m1_json).expect("pre-M1 shape must still parse");
833        assert!(v.severity.is_none());
834        assert!(v.decision.is_none());
835    }
836
837    #[test]
838    fn verdict_decision_canonical_values_match_js_py_go() {
839        // Lane-2 M1 acceptance: decision values match js/py/go SDK envelope.
840        // Schema-locked set: ALLOW | WARN | DENY | RISK_ACCEPTANCE_REQUIRED.
841        for d in ["ALLOW", "WARN", "DENY", "RISK_ACCEPTANCE_REQUIRED"] {
842            let v = Verdict {
843                decision: Some(d.to_string()),
844                ..Default::default()
845            };
846            let s = serde_json::to_string(&v).unwrap();
847            assert!(s.contains(&format!("\"decision\":\"{}\"", d)));
848        }
849    }
850
851    #[test]
852    fn verdict_severity_canonical_values_match_cleanlib_core() {
853        // Sister of `cleanlib-core::Severity` enum: NONE | LOW | MEDIUM | HIGH | CRITICAL.
854        for sev in ["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"] {
855            let v = Verdict {
856                severity: Some(sev.to_string()),
857                ..Default::default()
858            };
859            let s = serde_json::to_string(&v).unwrap();
860            assert!(s.contains(&format!("\"severity\":\"{}\"", sev)));
861        }
862    }
863}