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    /// 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///
280/// CLEANLIB-755/745: this struct used to carry ONLY `target_version` +
281/// `command_hint` — every other field the App's `verbs::Remediation` emits
282/// (`screened_at`, `peer_compatibility_checked`/`_note`, `clears`,
283/// `still_open`, `compatibility`) parsed successfully (serde silently drops
284/// unknown JSON keys with no `deny_unknown_fields`) but was then LOST on
285/// every re-serialize through this narrower client-side type — the exact
286/// mechanism behind two "already fixed" tickets both reproducing unchanged
287/// via `cleanlib verdict --output json`/`cleanlib fix`: 755's `screened_at`
288/// and 745's `peer_compatibility_checked`/`_note` disclosure. The App-side
289/// fields were genuinely wired (verified: `build_remediation` in
290/// `cleanlib-app/src/http.rs` sets them); the CLI just never had anywhere to
291/// put them. Widened to full parity with the wire shape so this class of
292/// silent-drop can't recur field-by-field, ticket-by-ticket ([SibSurface]).
293#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
294#[serde(default)]
295pub struct Remediation {
296    /// The version to upgrade to (upgrading clears every CVE the composite can).
297    pub target_version: String,
298    /// Per-ecosystem copy-paste upgrade command for the target.
299    pub command_hint: String,
300    /// CVE ids the target clears.
301    #[serde(default, skip_serializing_if = "Vec::is_empty")]
302    pub clears: Vec<String>,
303    /// CVE ids the target does NOT clear (findings with no known fix).
304    #[serde(default, skip_serializing_if = "Vec::is_empty")]
305    pub still_open: Vec<String>,
306    /// Compatibility label for the version jump. `Unknown` (the safe default,
307    /// matching the App's own "undetermined ≠ same_major" discipline) when
308    /// the server omitted it (a pre-702 App build) or the value was absent.
309    #[serde(default)]
310    pub compatibility: Compatibility,
311    /// CLEANLIB-755: the advisory CONSULTATION time the target was screened
312    /// against (not the request-compute stamp — see the App-side doc comment
313    /// on `verbs::Remediation::screened_at`). `None` when nothing was
314    /// consulted or the server didn't set it (omit, never fabricate "now").
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub screened_at: Option<String>,
317    /// CLEANLIB-745 (Bug 1): whether `target_version` was checked against
318    /// peerDependencies declared elsewhere in the customer's tree. This API
319    /// surface never receives the customer's lockfile, so it is `false` on
320    /// every target the App emits today — carried through here so a direct
321    /// API/MCP/CLI consumer of `verdict`/`remediation` sees the caveat
322    /// instead of treating an isolated-safe target as tree-safe.
323    #[serde(default)]
324    pub peer_compatibility_checked: bool,
325    /// Companion to `peer_compatibility_checked`: the reason, when `false`.
326    /// `None` on a pre-745 App build (omit, don't fabricate an explanation).
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub peer_compatibility_note: Option<String>,
329}
330
331/// CLEANLIB-702 remediation compatibility label — mirrors App
332/// `verbs::Compatibility` (`#[serde(rename_all = "snake_case")]`) wire-for-wire.
333/// `Unknown` is the `Default` (and the safe fallback for an unrecognized/absent
334/// wire value): an undetermined compatibility must never silently read as the
335/// reassuring `SameMajor`.
336#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
337#[serde(rename_all = "snake_case")]
338pub enum Compatibility {
339    /// Drop-in within the pinned compatibility boundary.
340    SameMajor,
341    /// A breaking move across the compatibility boundary, either direction.
342    MajorBump,
343    /// Compatibility could not be determined (e.g. an unparseable version).
344    #[default]
345    Unknown,
346}
347
348/// CLEANLIB-652 (CX-3) part 2: the App's nested per-axis `freshness` block
349/// (mirrors `verbs::Freshness`). Each axis age is `Option` — under precedence
350/// composition only the producing axis carries a timestamp and the others are
351/// null; `overall_as_of` = MIN of the non-null axis ages (the verdict is only as
352/// fresh as its stalest input). All-optional + `#[serde(default)]` for tolerant,
353/// forward-compatible parsing.
354#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
355#[serde(default)]
356pub struct Freshness {
357    pub cve_data_at: Option<String>,
358    pub behavioral_data_at: Option<String>,
359    pub policy_evaluated_at: Option<String>,
360    /// Which axis is stalest — drives the `overall_as_of` age.
361    pub stalest_axis: Option<String>,
362    /// MIN of the non-null per-axis ages.
363    pub overall_as_of: Option<String>,
364}
365
366/// CLEANLIB-613: the App's `top_findings` block on a customer verdict — the
367/// per-CVE findings that back a VECTOR_VERDICT/DENY, plus the KEV / ransomware
368/// flags. Modeled so the CLI `fix` command can reach each finding's structured
369/// `fixed_version` (the App's always-populated remediation target). Tolerant:
370/// `#[serde(default)]`, all-optional, no `deny_unknown_fields`.
371#[derive(Debug, Clone, Deserialize, Serialize, Default)]
372#[serde(default)]
373pub struct TopFindings {
374    /// Total CVE count backing this verdict (may exceed `findings.len()` when the
375    /// App truncates to the top-N; the summary still counts them all).
376    pub cve_count: Option<u32>,
377    pub on_kev: Option<bool>,
378    pub on_ransomware: Option<bool>,
379    /// The per-CVE findings. Each carries its own `fixed_version`; the CLI `fix`
380    /// command takes the max across these as the cumulative upgrade target.
381    pub findings: Vec<Finding>,
382}
383
384/// CLEANLIB-613: one CVE finding inside [`TopFindings`]. Only the fields the CLI
385/// consumes are typed; the App may add more (tolerated — no `deny_unknown_fields`).
386#[derive(Debug, Clone, Deserialize, Serialize, Default)]
387#[serde(default)]
388pub struct Finding {
389    pub cve_id: Option<String>,
390    /// The version that remediates THIS CVE. `fix` takes the max of these across
391    /// findings as the cumulative upgrade target (you must reach at least the
392    /// highest per-CVE fix to clear every CVE). `None` when no fix exists yet.
393    pub fixed_version: Option<String>,
394    pub vulnerable_versions: Option<String>,
395    pub severity: Option<String>,
396    pub cvss_v3_score: Option<f64>,
397}
398
399/// CLEANLIB-601: typed subset of the App's `rich_data` block. Carries the
400/// `recommended_version` upgrade target for the CLI `fix` command. `#[serde(default)]`
401/// + no `deny_unknown_fields` — other `rich_data` keys (evidence/composition, which
402/// the App also hoists to top-level passthrough fields above) are tolerated, and a
403/// `rich_data` object missing `recommended_version` deserializes to `None`.
404#[derive(Debug, Clone, Deserialize, Serialize, Default)]
405#[serde(default)]
406pub struct RichData {
407    /// The App's suggested upgrade target (e.g. `"4.17.21"`). `None` when the
408    /// App emits no recommendation for this coordinate.
409    pub recommended_version: Option<String>,
410}
411
412/// Cycle-15 honesty signal block on the SDK Verdict shape. Mirrors the App
413/// wire-shape `cleanlib_core::AvailabilityBlock`. `Option<bool>`-style
414/// passthrough for `degraded_stale` so pre-cycle-15 payloads (without the
415/// block) deserialize cleanly.
416///
417/// CLEANLIB-105 App-3.2 M1/M2 additions: `kev` / `epss` /
418/// `exploitation_fusion` sub-fields as `Option<String>` (SDK-passthrough
419/// per §5 ripple discipline). String tags: `"available"` |
420/// `"not_applicable"` | `"unavailable"` | `"degraded_stale"` per
421/// `cleanlib_core::FieldAvailability` snake_case serde. `Option` on the
422/// SDK side (vs `FieldAvailability` non-Optional on the App side) lets
423/// pre-M1 payloads without any sub-field key deserialize cleanly to
424/// `None` — the SDK's `derive_status.rs` treats `None` and
425/// `"unavailable"` identically (both fail the "== Some(\"available\")"
426/// check on lines 76+).
427///
428/// NOTE: no `skip_serializing_if` on any field — this struct is bincode-
429/// serialized (positionally) by `cleanlib-cli::PersistentCache`, and
430/// conditional omission would corrupt the cache alignment (§CLEANLIB-104
431/// design doc §3.M3 cache-shape note). The parent `Verdict` documents this
432/// invariant at the `previous_verdict` field. Fields that need to be omitted
433/// from the customer-facing JSON envelope are re-shaped by
434/// [`crate::verdict_to_envelope::verdict_to_envelope_v1`] (which is the
435/// customer wire path), not by field-level serde attributes here.
436#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
437#[serde(default)]
438pub struct AvailabilityBlock {
439    pub degraded_stale: bool,
440    /// CISA KEV substrate availability tag
441    /// (`"available"` | `"not_applicable"` | `"unavailable"` | `"degraded_stale"`).
442    pub kev: Option<String>,
443    /// FIRST.org EPSS substrate availability tag.
444    pub epss: Option<String>,
445    /// Composite exploitation-likelihood availability tag.
446    pub exploitation_fusion: Option<String>,
447}
448
449/// CLEANLIB-780: the per-axis result envelope (mirrors the App's `axes` on
450/// `CustomerVerdictResponse`). Each plane is reported SEPARATELY so the client
451/// renders honest per-axis language. Tolerant: struct-level `#[serde(default)]` +
452/// `Default`, no `deny_unknown_fields`, so a partial or future-extended envelope
453/// never fails the reader.
454#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
455#[serde(default)]
456pub struct Axes {
457    pub advisory: AxisAdvisory,
458    pub threat: AxisThreat,
459    pub availability: AxisAvailability,
460}
461
462/// CLEANLIB-780 advisory (CVE cross-reference) axis. `result` is the App's
463/// closed enum `clean` | `vulnerable` | `never_consulted` — DERIVED, never a
464/// false clean. `sources_consulted` lists ONLY sources proven to have
465/// contributed data and is ABSENT on a clean/empty result (the App does not
466/// fabricate a source list), so the client must render only the sources present
467/// and never a hardcoded set.
468#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
469#[serde(default)]
470pub struct AxisAdvisory {
471    pub ran: bool,
472    pub result: String,
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub sources_consulted: Option<Vec<String>>,
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub consulted_at: Option<String>,
477    pub advisory_count: usize,
478}
479
480/// CLEANLIB-780 threat (behavioral / Vector triage) axis — the empirical split
481/// from advisory. `result` is `malicious` | `blocked` | `clean` |
482/// `never_consulted`. CVE findings are the ADVISORY axis and are NOT counted here.
483#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
484#[serde(default)]
485pub struct AxisThreat {
486    pub ran: bool,
487    pub result: String,
488    pub finding_count: usize,
489    #[serde(skip_serializing_if = "Option::is_none")]
490    pub evaluated_at: Option<String>,
491}
492
493/// CLEANLIB-780 availability (byte-serve fingerprint) axis. Not evaluated on the
494/// verdict path, so `ran` is honestly `false` — a scope statement, not a failure.
495#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
496#[serde(default)]
497pub struct AxisAvailability {
498    pub ran: bool,
499    pub result: String,
500}
501
502/// Prior-verdict comparison. Surfaces when the CleanLibrary App has a
503/// stored prior verdict for the same `(ecosystem, package, version)` that
504/// differs from the current one — useful for AI agents and dashboards
505/// that want to flag verdict-state changes since the last fetch.
506/// Sister-shape with `cleanlib_sdk_go::PreviousVerdict` and
507/// `cleanlib-core::PreviousVerdict` in the App.
508#[derive(Debug, Clone, Default, Deserialize, Serialize)]
509#[serde(default)]
510pub struct PreviousVerdict {
511    pub verdict_id: String,
512    pub verdict: String,
513    pub computed_at: String,
514    pub diff: String,
515}
516
517impl Default for Verdict {
518    fn default() -> Self {
519        Self {
520            verdict_id: String::new(),
521            verdict: String::new(),
522            source: String::new(),
523            confidence: 0.0,
524            composite_score: 0,
525            reasoning: String::new(),
526            similar_to: Vec::new(),
527            evidence_gaps: Vec::new(),
528            suggested_actions: Vec::new(),
529            data_freshness_at: None,
530            data_oldest_signal_at: None,
531            stale_since_at: None,
532            staleness_reason: None,
533            computed_at: None,
534            severity: None,
535            decision: None,
536            previous_verdict: None,
537            availability: AvailabilityBlock::default(),
538            // CLEANLIB-412 envelope-v2 (Step-6 phase-a) — absent on v1.
539            envelope_version: None,
540            customer_state: None,
541            state: None,
542            source_state: None,
543            policy_version: None,
544            audit_record_id: None,
545            audit_record_hash: None,
546            attestation: None,
547            // CLEANLIB-518 §4 tolerant-passthrough — absent unless the App
548            // enhanced-verdict flag is on.
549            evidence: None,
550            composition: None,
551            // CLEANLIB-601 — absent unless the App emits a rich_data block.
552            rich_data: None,
553            // CLEANLIB-613 — absent on v1; populated on customer-verdict responses.
554            top_findings: None,
555            // CLEANLIB-652 (CX-3) part 1 — absent on v1; App emits on envelope-v2.
556            reason_class: None,
557            attestation_status: None,
558            // CLEANLIB-652 (CX-3) part 2 — nested per-axis freshness; absent on v1.
559            freshness: None,
560            // CLEANLIB-652 (CX-3) part 3a — structured remediation; absent on v1.
561            remediation: None,
562            // CLEANLIB-780 — per-axis result envelope; absent on v1.
563            axes: None,
564            // CLEANLIB-855 — matched policy rule; absent on v1 and on any
565            // non-policy verdict source.
566            matched_rule_id: None,
567        }
568    }
569}
570
571/// One package identity for policy-preview / scan requests.
572///
573/// Wire-contract note: the App-side coordinate struct
574/// (`cleanlib-app::verbs::PackageRef`, shared by `POST /v1/scan` +
575/// `POST /v1/policy/preview`) names this field `package`, not `name`.
576/// Serializing the Rust identifier `name` verbatim made the App reject the
577/// body with `422 … packages[0]: missing field \`package\``, breaking both
578/// `cleanlib scan` and `cleanlib policy preview`. The `#[serde(rename)]` puts
579/// `package` on the wire while keeping the `name` identifier that the
580/// packages-file parsers in `commands::scan` already construct.
581#[derive(Debug, Clone, Deserialize, Serialize)]
582pub struct PackageRef {
583    pub ecosystem: String,
584    #[serde(rename = "package")]
585    pub name: String,
586    pub version: String,
587}
588
589/// Body of `POST /v1/policy/preview` — packages + optional
590/// hypothetical policy override (JSON-shaped; YAML-source customers
591/// convert client-side).
592#[derive(Debug, Clone, Serialize)]
593pub struct PolicyPreviewRequest {
594    pub packages: Vec<PackageRef>,
595    #[serde(skip_serializing_if = "Option::is_none")]
596    pub policy: Option<serde_json::Value>,
597}
598
599/// Per-package decision returned from `/v1/policy/preview` or
600/// embedded in audit entries.
601#[derive(Debug, Clone, Deserialize, Serialize, Default)]
602#[serde(default)]
603pub struct PolicyDecision {
604    pub ecosystem: String,
605    pub package: String,
606    pub version: String,
607    /// `ALLOW` | `DENY` | `WARN` | `INSUFFICIENT_DATA` | `RISK_ACCEPTANCE_REQUIRED`
608    pub decision: String,
609    /// CLEANLIB-666: the `/v1/policy/preview` wire names this `reasoning`
610    /// (`verbs::PolicyPreviewResult.reasoning`); scan/audit surfaces use `reason`.
611    /// Alias reads both so the same struct parses every producer.
612    #[serde(alias = "reasoning")]
613    pub reason: String,
614    pub verdict_id: Option<String>,
615    /// CLEANLIB-666: `/v1/policy/preview` names the matched rule `matched_rule_id`
616    /// on the wire; other surfaces use `policy_rule_id`. Alias reads both.
617    #[serde(alias = "matched_rule_id")]
618    pub policy_rule_id: Option<String>,
619    /// CLEANLIB-616: set when this coordinate could NOT be evaluated — a per-package
620    /// scan error, or a chunk that failed/returned no result (mirrors
621    /// `ScanResult.error`). Lets `--output json` distinguish a NEVER-EVALUATED
622    /// coordinate from one that was evaluated and warned: both surface as a WARN
623    /// `decision`, but only the unevaluated one carries `error`. `None` (and
624    /// omitted from JSON) on an evaluated coordinate.
625    #[serde(skip_serializing_if = "Option::is_none")]
626    pub error: Option<String>,
627}
628
629/// Response from `POST /v1/policy/preview`.
630#[derive(Debug, Clone, Deserialize, Serialize, Default)]
631#[serde(default)]
632pub struct PolicyPreviewResponse {
633    /// CLEANLIB-666: the App emits this array as `results`
634    /// (`verbs::PolicyPreviewResponse.results`), not `decisions`. Without the
635    /// alias the client parsed every real preview response into an EMPTY vec —
636    /// the verb silently returned nothing (exit 0) even after CLEANLIB-631/#362
637    /// fixed the request side. Alias makes the client read the real wire.
638    #[serde(alias = "results")]
639    pub decisions: Vec<PolicyDecision>,
640    /// CLEANLIB-666 residual: the App emits `policy_version` alongside `results`
641    /// (`verbs::PolicyPreviewResponse.policy_version`) — the version of the policy
642    /// these decisions were evaluated against. The response struct previously had
643    /// no field for it, so it was silently dropped (gate376 flagged
644    /// `policy_version DROPPED`). Capture it so `--output json` faithfully reports
645    /// which policy version produced the decisions. `#[serde(default)]` on the
646    /// struct keeps this back-compat for responses that omit it (→ `None`).
647    #[serde(default, skip_serializing_if = "Option::is_none")]
648    pub policy_version: Option<String>,
649    /// CLEANLIB-480 · the `x-request-id` header the App emits on every
650    /// response (per CLEANLIB-470, live on all 4 response classes). Populated
651    /// from the RESPONSE HEADER by `transport::Client::policy_preview` after
652    /// the JSON body has been parsed — the wire body itself carries no
653    /// `request_id` field (`#[serde(default)]` → `None` on deserialize).
654    /// SDK callers doing correlation debugging (e.g. partner reporting an
655    /// issue, log correlation) read this directly instead of falling back
656    /// to a raw HTTP client bypass.
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub request_id: Option<String>,
659}
660
661/// Body of `POST /v1/scan` — a batch of package coordinates, no policy.
662///
663/// Distinct from [`PolicyPreviewRequest`]: `cleanlib scan` previews packages
664/// against the customer's *active* policy (verdict-driven, server-side), so it
665/// carries no `policy_yaml`. Routing `scan` through `/v1/policy/preview`
666/// (which requires `policy_yaml`) was the 422 that hid behind the earlier
667/// `package`-field fix.
668#[derive(Debug, Clone, Serialize)]
669pub struct ScanRequest {
670    pub packages: Vec<PackageRef>,
671}
672
673/// One entry of the `POST /v1/scan` response. Mirrors the App's
674/// `verbs::ScanResult` wire shape: the package coordinate is flattened
675/// (`ecosystem` / `package` / `version`) alongside an optional `verdict`
676/// (present on success) or `error` string (per-package partial failure —
677/// the App resolves each package independently and never fails the whole
678/// batch on one miss).
679#[derive(Debug, Clone, Deserialize, Serialize, Default)]
680#[serde(default)]
681pub struct ScanResult {
682    pub ecosystem: String,
683    pub package: String,
684    pub version: String,
685    pub verdict: Option<Verdict>,
686    pub error: Option<String>,
687    // ─── CLEANLIB-652 [SibSurface] (#372): per-package v2 envelope fields ─────
688    // App #372 flattens a ScanVerdictEnvelope onto each ScanResult, so these ride
689    // as SIBLINGS of `verdict` (which stays nested) — the client dropped them
690    // before this. All `Option` + the struct-level `#[serde(default)]` → v1 / thin
691    // results deserialize `None` (back-compat), no `deny_unknown_fields`. Names
692    // mirror the App wire exactly. Reuses [`Freshness`]/[`Remediation`] (CX-3
693    // part-2/3a). Surfaced on the scan surface via `decision_from_result`.
694    pub customer_state: Option<String>,
695    pub state: Option<String>,
696    pub source_state: Option<String>,
697    pub reason_class: Option<String>,
698    pub attestation_status: Option<String>,
699    pub freshness: Option<Freshness>,
700    pub remediation: Option<Remediation>,
701}
702
703/// CLEANLIB-652 (CX-3) part 3b / CLEANLIB-647 (DD-1): one per-coordinate
704/// not-assessed reason from the App's per-request `coverage` block. The App emits
705/// this for every coordinate it returned but could NOT assess (see
706/// [`Coverage::not_assessed_reasons`]); the client joins it into the per-decision
707/// `error` field so `scan --output json` can distinguish a NEVER-EVALUATED
708/// coordinate from one that was evaluated and warned.
709#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
710#[serde(default)]
711pub struct NotAssessedReason {
712    /// `"{ecosystem}/{package}@{version}"` — the App's coordinate identity,
713    /// matching the client's own `format!("{}/{}@{}", …)` join key.
714    pub coordinate: String,
715    /// Coverage-scoped reason class (`INSUFFICIENT_DATA` | `RANGE_NOT_RESOLVED`).
716    pub reason_class: String,
717}
718
719/// CLEANLIB-652 (CX-3) part 3b / CLEANLIB-647 (DD-1): the App's per-request
720/// `coverage` block on the `POST /v1/scan` response — how many coordinates were
721/// assessed vs not, plus the per-coordinate attribution the client required
722/// (652 c767287) so DD-1 can populate `error` on each never-evaluated coordinate.
723#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
724#[serde(default)]
725pub struct Coverage {
726    pub assessed: usize,
727    pub not_assessed: usize,
728    /// Per-coordinate reasons; empty (and omitted on the wire) when every
729    /// coordinate was assessed.
730    pub not_assessed_reasons: Vec<NotAssessedReason>,
731}
732
733/// Response from `POST /v1/scan`. One [`ScanResult`] per requested package.
734#[derive(Debug, Clone, Deserialize, Serialize, Default)]
735#[serde(default)]
736pub struct ScanResponse {
737    pub results: Vec<ScanResult>,
738    /// CLEANLIB-652 (CX-3) part 3b: per-request coverage. `None` on v1 / pre-part-3b
739    /// responses (struct-level `#[serde(default)]` → back-compat).
740    pub coverage: Option<Coverage>,
741    /// CLEANLIB-669 [SibSurface-pre-emption]: the active policy-bundle version for
742    /// this scan request — the App emits it TOP-LEVEL on the /v1/scan response
743    /// (sibling of `results`/`coverage`, App PR #378), per-request not per-result.
744    /// Without this field the client silently DROPS it at parse (the same
745    /// App-emit-needs-Client-consume pairing as [SibSurface]). `None` on v1 /
746    /// envelope-v2-off (`#[serde(default)]` → back-compat).
747    pub policy_version: Option<String>,
748    /// CLEANLIB-480 · the `x-request-id` header the App emits on every
749    /// response — populated from the RESPONSE HEADER by
750    /// `transport::Client::scan`. See [`PolicyPreviewResponse::request_id`]
751    /// for the rationale.
752    #[serde(default, skip_serializing_if = "Option::is_none")]
753    pub request_id: Option<String>,
754}
755
756/// One audit log entry returned from `GET /v1/audit`.
757///
758/// **CLEANLIB-366 — App wire is source of truth.** Mirrors the App-side
759/// `AuditRow` (cleanlib-audit-clickhouse) which the App serializes into each
760/// element of the `records` array. Prior CLI struct silently dropped fields
761/// because names had drifted (`package` vs App's `package_name`, `version` vs
762/// `package_version`, `decision` vs `policy_decision`, `reason` vs
763/// `reasoning`, `at` vs `request_at`) — with `#[serde(default)]` deserialize
764/// succeeded and every field came back empty. Same class as CLEANLIB-348.
765///
766/// Field names below match `AuditRow` exactly. All fields default-tolerant
767/// via struct-level `#[serde(default)]` so partial responses or App-side
768/// schema evolution do not force a CLI recompile.
769///
770/// UUID fields on the App side (`request_id`, `verdict_id`) serialize as
771/// hyphenated strings when populated; datetime fields (`request_at`,
772/// `verdict_at`, `response_at`, …) serialize as RFC 3339 strings — hence
773/// `String` for those. `request_id` / `verdict_id` are `Option<String>` —
774/// see [`deserialize_optional_audit_id_normalize_nil`] for why (CLEANLIB-743
775/// / CLEANLIB-813, 2026-09-15 crash fix).
776///
777/// CLEANLIB-794 (App-side) — defensive [Degr≡Real] guard for the App's
778/// `request_id` / `verdict_id` placeholders on audit records.
779///
780/// The persisted ClickHouse audit row historically carries no real
781/// `request_id` / `verdict_id` for these rows (the WORM decision SoR doesn't
782/// capture them). The App used to emit the nil-UUID
783/// (`"00000000-0000-0000-0000-000000000000"`) as a stand-in, which forwarded
784/// to callers as if it were a real per-record identifier (the CLI's audit
785/// table showed every row with the same fake ULID). The App's own CLEANLIB-794
786/// fix now masks that nil-UUID to a literal JSON `null` at the customer
787/// serialization boundary (`mask_placeholder_uuids_in_audit_row` in
788/// `cleanlib-app::verbs`) — which is an HONEST wire signal, but this struct
789/// declared both fields as plain non-optional `String` with (at most) a
790/// nil-UUID-*string* normalizer, so a literal `null` hard-failed
791/// deserialization: `invalid type: null, expected a string`. Verified this
792/// crashes the real published `cleanlib-cli 0.1.21` / `cleanlib-client 0.2.0`
793/// against real production data — `cleanlib audit` could not complete at
794/// ALL, filtered or not (CLEANLIB-743 retroactive audit, CLEANLIB-813 same
795/// root cause via the `until` filter). Fixed here by making both fields
796/// `Option<String>`, `None` meaning "no id recorded for this row" — the
797/// same honest-absence contract the rest of this file already uses
798/// everywhere else (see [`Verdict`]'s many `Option<String>` fields).
799fn deserialize_optional_audit_id_normalize_nil<'de, D>(
800    deserializer: D,
801) -> Result<Option<String>, D::Error>
802where
803    D: serde::Deserializer<'de>,
804{
805    use serde::Deserialize;
806    // Field may be absent (struct-level `#[serde(default)]` → None before
807    // this function even runs), a literal JSON `null` (the App's CLEANLIB-794
808    // boundary mask — the crash case this fixes), the legacy nil-UUID
809    // *string* placeholder (defense-in-depth for any code path that still
810    // emits the old sentinel instead of `null`), an empty string, or a real
811    // id. The first four all normalize to `None`; anything else passes
812    // through as `Some(value)`. Consolidating empty-string into `None` too
813    // (rather than keeping it as a distinct third "honest absence" shape)
814    // gives every caller ONE canonical way to check "was this stamped?":
815    // `.is_some()`.
816    let raw: Option<String> = Option::deserialize(deserializer)?;
817    match raw {
818        None => Ok(None),
819        Some(s) if s.is_empty() => Ok(None),
820        Some(s) if s == "00000000-0000-0000-0000-000000000000" => Ok(None),
821        Some(s) => Ok(Some(s)),
822    }
823}
824
825#[derive(Debug, Clone, Deserialize, Serialize, Default)]
826#[serde(default)]
827pub struct AuditEntry {
828    // request identification
829    /// `None` when the App has no per-request id for this row (either a
830    /// literal wire `null` — CLEANLIB-794's boundary mask — or the legacy
831    /// nil-UUID string placeholder). `Some(id)` for a genuinely stamped
832    /// request. CLEANLIB-743/813 (2026-09-15): was plain non-optional
833    /// `String`, which hard-crashed `cleanlib audit` against real production
834    /// data the moment the App started emitting `null` for this field.
835    #[serde(deserialize_with = "deserialize_optional_audit_id_normalize_nil")]
836    pub request_id: Option<String>,
837    pub correlation_id: String,
838
839    // request shape
840    pub ecosystem: String,
841    pub package_name: String,
842    pub package_version: String,
843    pub variant: String,
844
845    // decision
846    pub policy_decision: String,
847    /// `None` when the WORM decision SoR behind this audit row carries no
848    /// verdict id (same class as `request_id` above — App CLEANLIB-794 masks
849    /// its nil-UUID placeholder to `null` at the wire boundary).
850    /// CLEANLIB-743/813 (2026-09-15): was plain non-optional `String` with no
851    /// deserializer at all — the actual crash site Test-mgr's live repro hit
852    /// first (`verdict_id` appears earlier than `request_id` in the App's
853    /// real field order).
854    #[serde(deserialize_with = "deserialize_optional_audit_id_normalize_nil")]
855    pub verdict_id: Option<String>,
856    pub verdict_source: String,
857    pub policy_rule_id_matched: String,
858    pub risk_acceptance_status: String,
859    pub reasoning: String,
860
861    // catalog
862    pub gcs_hit: bool,
863
864    // timing (RFC 3339 strings)
865    pub request_at: String,
866    pub verdict_at: String,
867    pub response_at: String,
868
869    // metadata
870    pub app_version: String,
871}
872
873/// Query-window echo returned inside [`AuditResponse::window`]. Mirrors the
874/// App-side `AuditWindow` — echoes the caller's `since` / `until` filter
875/// values verbatim (or `None` when the filter was omitted).
876#[derive(Debug, Clone, Deserialize, Serialize, Default)]
877#[serde(default)]
878pub struct AuditWindow {
879    pub since: Option<String>,
880    pub until: Option<String>,
881}
882
883/// Response from `GET /v1/audit`. Mirrors the App-side `AuditResponse` in
884/// `cleanlib-app::verbs`. See [`AuditEntry`] for the CLEANLIB-366 field-name
885/// alignment note.
886///
887/// `backend_status` is `"wired"` when the App has an `AuditReader` attached
888/// and the read succeeded, `"not_wired"` when no reader is configured, or
889/// `"read_error"` when the reader errored. CLI callers surface this signal
890/// so customers can distinguish "empty because no rows" from "empty because
891/// the audit backend is offline".
892#[derive(Debug, Clone, Deserialize, Serialize, Default)]
893#[serde(default)]
894pub struct AuditResponse {
895    pub window: AuditWindow,
896    pub records: Vec<AuditEntry>,
897    pub record_count: usize,
898    pub per_route: std::collections::BTreeMap<String, usize>,
899    pub backend_status: String,
900    /// CLEANLIB-480 · the `x-request-id` header the App emits on every
901    /// response — populated from the RESPONSE HEADER by
902    /// `transport::Client::audit`. Distinct from [`AuditEntry::request_id`]
903    /// (which is the record-scoped request identifier PERSISTED PER ROW);
904    /// this is the ULID of the current `GET /v1/audit` call that returned
905    /// this response body. See [`PolicyPreviewResponse::request_id`] for
906    /// the rationale.
907    #[serde(default, skip_serializing_if = "Option::is_none")]
908    pub request_id: Option<String>,
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914
915    #[test]
916    fn parses_minimal_verdict() {
917        let json = r#"{
918            "verdict_id": "01JBYK000",
919            "verdict": "ALLOWED_NO_FINDINGS",
920            "source": "ALLOWED_NO_FINDINGS"
921        }"#;
922        let v: Verdict = serde_json::from_str(json).unwrap();
923        assert_eq!(v.verdict_id, "01JBYK000");
924        assert_eq!(v.verdict, "ALLOWED_NO_FINDINGS");
925        assert_eq!(v.confidence, 0.0);
926        assert!(v.similar_to.is_empty());
927    }
928
929    #[test]
930    fn cleanlib_780_v1_verdict_has_no_axes() {
931        // Back-compat: a v1 payload omits `axes` -> None (never a parse error).
932        let json = r#"{"verdict_id":"01A","verdict":"ALLOWED_NO_FINDINGS","source":"ALLOWED_NO_FINDINGS"}"#;
933        let v: Verdict = serde_json::from_str(json).unwrap();
934        assert!(v.axes.is_none());
935    }
936
937    #[test]
938    fn cleanlib_780_parses_axes_consulted_empty_clean() {
939        // The GREEN-flip case: advisory consulted + empty -> result "clean" with
940        // the sources that PROVABLY contributed. Mirrors App's #477 wire exactly.
941        let json = r#"{
942            "verdict_id":"01B","verdict":"ALLOWED_NO_FINDINGS","source":"ALLOWED_NO_FINDINGS",
943            "axes":{
944                "advisory":{"ran":true,"result":"clean","sources_consulted":["cve","nvd","ghsa"],"consulted_at":"2026-09-02T08:00:00Z","advisory_count":0},
945                "threat":{"ran":true,"result":"clean","finding_count":0,"evaluated_at":"2026-09-02T08:00:00Z"},
946                "availability":{"ran":false,"result":"not_run"}
947            }
948        }"#;
949        let v: Verdict = serde_json::from_str(json).unwrap();
950        let axes = v.axes.expect("axes present under v2");
951        assert_eq!(axes.advisory.result, "clean");
952        assert_eq!(axes.advisory.sources_consulted.as_deref(), Some(&["cve".to_string(),"nvd".to_string(),"ghsa".to_string()][..]));
953        assert_eq!(axes.advisory.consulted_at.as_deref(), Some("2026-09-02T08:00:00Z"));
954        assert_eq!(axes.advisory.advisory_count, 0);
955        assert_eq!(axes.threat.result, "clean");
956        assert!(!axes.availability.ran);
957    }
958
959    #[test]
960    fn cleanlib_780_parses_axes_never_consulted_no_sources() {
961        // never_consulted: sources_consulted ABSENT (App does not fabricate a
962        // source list) -> None, so the client renders no source enumeration.
963        let json = r#"{
964            "verdict_id":"01C","verdict":"INSUFFICIENT_DATA","source":"INSUFFICIENT_DATA",
965            "axes":{
966                "advisory":{"ran":false,"result":"never_consulted","advisory_count":0},
967                "threat":{"ran":false,"result":"never_consulted","finding_count":0},
968                "availability":{"ran":false,"result":"not_run"}
969            }
970        }"#;
971        let v: Verdict = serde_json::from_str(json).unwrap();
972        let axes = v.axes.expect("axes present");
973        assert_eq!(axes.advisory.result, "never_consulted");
974        assert!(axes.advisory.sources_consulted.is_none(), "no fabricated source list");
975        assert!(axes.advisory.consulted_at.is_none());
976    }
977
978    /// CLEANLIB-468 regression: the live envelope-v2 Path-A wire — where the
979    /// top-level `verdict` key is a nested OBJECT and the label rides
980    /// `verdict_label` — must deserialize WITHOUT the `invalid type: map,
981    /// expected a string` error that broke Ajeet-Yadav's CLI (CLEANLIB-462).
982    #[test]
983    fn parses_envelope_v2_path_a_wire_with_verdict_object_and_label() {
984        let json = r#"{
985            "verdict_id": "01JBYK042",
986            "verdict": { "type": "VECTOR_VERDICT", "status": "WARN", "customer_state": "vulnerable" },
987            "verdict_label": "VECTOR_VERDICT",
988            "source": "CVE_FINDING",
989            "envelope_version": 2,
990            "customer_state": "vulnerable",
991            "state": "WARN",
992            "source_state": "CVE_FINDING"
993        }"#;
994        // Must NOT error (the nested `verdict` object is skipped; label read from
995        // `verdict_label`).
996        let v: Verdict = serde_json::from_str(json)
997            .expect("Path-A v2 wire must deserialize — CLEANLIB-468");
998        assert_eq!(v.verdict, "VECTOR_VERDICT");
999        assert_eq!(v.source, "CVE_FINDING");
1000        assert_eq!(v.customer_state.as_deref(), Some("vulnerable"));
1001        assert_eq!(v.source_state.as_deref(), Some("CVE_FINDING"));
1002    }
1003
1004    #[test]
1005    fn parses_full_verdict() {
1006        let json = r#"{
1007            "verdict_id": "01JBYK001",
1008            "verdict": "VECTOR_VERDICT",
1009            "source": "VECTOR_VERDICT",
1010            "confidence": 0.98,
1011            "composite_score": 92,
1012            "reasoning": "Confirmed malware",
1013            "similar_to": ["01JBYK999"],
1014            "evidence_gaps": [],
1015            "suggested_actions": ["DENY across customers"],
1016            "data_freshness_at": "2026-05-21T10:00:00Z",
1017            "computed_at": "2026-05-21T10:01:00Z"
1018        }"#;
1019        let v: Verdict = serde_json::from_str(json).unwrap();
1020        assert_eq!(v.composite_score, 92);
1021        assert_eq!(v.confidence, 0.98);
1022        assert_eq!(v.similar_to.len(), 1);
1023        assert_eq!(v.suggested_actions[0], "DENY across customers");
1024    }
1025
1026    #[test]
1027    fn cleanlib_613_parses_top_findings_fixed_version() {
1028        // Locks the wire contract for the CLI `fix` remediation source. Captured
1029        // from cleanapp.clnstrt.dev/v1/customer/verdicts/npm/lodash/4.17.20
1030        // (2026-08-20): `verdict` is a nested object, `rich_data` is null, and the
1031        // remediation target lives in `top_findings.findings[].fixed_version`.
1032        let json = r#"{
1033            "verdict_id": "01JBYK613",
1034            "verdict": {"customer_state":"vulnerable","status":"WARN","type":"VECTOR_VERDICT"},
1035            "source": "CVE_FINDING",
1036            "rich_data": null,
1037            "suggested_actions": ["Upgrade to 4.18.0+ to address CVE-2026-4800 (HIGH, CVSS 8.1)"],
1038            "top_findings": {
1039                "cve_count": 6,
1040                "on_kev": false,
1041                "on_ransomware": false,
1042                "findings": [
1043                    {"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},
1044                    {"cve_id":"CVE-2021-23337","fixed_version":"4.17.21","severity":"HIGH"}
1045                ]
1046            }
1047        }"#;
1048        let v: Verdict = serde_json::from_str(json).unwrap();
1049        // The nested-object `verdict` still deserializes to its `type` (de_verdict_label).
1050        assert_eq!(v.verdict, "VECTOR_VERDICT");
1051        assert!(v.rich_data.is_none());
1052        let tf = v.top_findings.expect("top_findings must parse");
1053        assert_eq!(tf.cve_count, Some(6));
1054        assert_eq!(tf.findings.len(), 2);
1055        assert_eq!(tf.findings[0].fixed_version.as_deref(), Some("4.18.0"));
1056        assert_eq!(tf.findings[0].cve_id.as_deref(), Some("CVE-2026-4800"));
1057        // A finding omitting optional fields still parses (serde default tolerance).
1058        assert_eq!(tf.findings[1].fixed_version.as_deref(), Some("4.17.21"));
1059        assert!(tf.findings[1].cvss_v3_score.is_none());
1060    }
1061
1062    #[test]
1063    fn parses_policy_preview_response() {
1064        let json = r#"{
1065            "decisions": [
1066                {"ecosystem":"npm","package":"left-pad","version":"1.3.0","decision":"ALLOW","reason":"ok"},
1067                {"ecosystem":"npm","package":"event-stream","version":"3.3.6","decision":"DENY","reason":"malware","verdict_id":"01JBYK999"}
1068            ]
1069        }"#;
1070        let resp: PolicyPreviewResponse = serde_json::from_str(json).unwrap();
1071        assert_eq!(resp.decisions.len(), 2);
1072        assert_eq!(resp.decisions[0].decision, "ALLOW");
1073        assert_eq!(resp.decisions[1].decision, "DENY");
1074        assert_eq!(resp.decisions[1].verdict_id.as_deref(), Some("01JBYK999"));
1075    }
1076
1077    /// CLEANLIB-366 — deserialize against the App's real wire shape
1078    /// (`{window, records, record_count, per_route, backend_status}`) and
1079    /// assert every renamed field (`package_name`, `package_version`,
1080    /// `policy_decision`, `reasoning`, `request_at`) round-trips a non-empty
1081    /// value. The pre-fix struct used `entries` + `{package, version,
1082    /// decision, reason, at}` and silently dropped every field on this
1083    /// payload because names did not match.
1084    #[test]
1085    fn parses_audit_response_matches_app_wire_shape() {
1086        let json = r#"{
1087            "window": {"since": "2026-05-22T00:00:00Z", "until": "2026-05-23T00:00:00Z"},
1088            "records": [{
1089                "request_id": "01936b8f-3c4a-7a12-9c00-000000000001",
1090                "correlation_id": "corr-1",
1091                "customer_ip_hashed": "sha256:aaaa",
1092                "ecosystem": "npm",
1093                "package_name": "lodash",
1094                "package_version": "4.17.21",
1095                "variant": "default",
1096                "user_agent": "cleanlib-cli/0.1.4",
1097                "policy_decision": "ALLOW",
1098                "verdict_id": "01936b8f-3c4a-7a12-9c00-0000000000aa",
1099                "verdict_source": "ALLOWED_NO_FINDINGS",
1100                "policy_rule_id_matched": "rule-42",
1101                "risk_acceptance_status": "NONE",
1102                "reasoning": "ok",
1103                "gcs_hit": true,
1104                "gcs_object_path": "gs://bucket/obj",
1105                "bytes_served": 4096,
1106                "request_at": "2026-05-22T10:00:00Z",
1107                "ingest_at": null,
1108                "gcs_at": null,
1109                "verdict_at": "2026-05-22T10:00:01Z",
1110                "policy_eval_at": "2026-05-22T10:00:02Z",
1111                "response_at": "2026-05-22T10:00:03Z",
1112                "app_version": "1.2.3"
1113            }],
1114            "record_count": 1,
1115            "per_route": {"/v1/customer/verdicts/npm": 1},
1116            "backend_status": "wired"
1117        }"#;
1118        let resp: AuditResponse = serde_json::from_str(json).unwrap();
1119        assert_eq!(resp.records.len(), 1);
1120        assert_eq!(resp.record_count, 1);
1121        assert_eq!(resp.backend_status, "wired");
1122        assert_eq!(resp.window.since.as_deref(), Some("2026-05-22T00:00:00Z"));
1123        assert_eq!(resp.per_route.get("/v1/customer/verdicts/npm"), Some(&1));
1124
1125        let e = &resp.records[0];
1126        // Every renamed field must carry a value — the pre-fix struct would
1127        // have left these empty because the JSON keys did not match.
1128        assert_eq!(
1129            e.request_id.as_deref(),
1130            Some("01936b8f-3c4a-7a12-9c00-000000000001")
1131        );
1132        assert_eq!(e.correlation_id, "corr-1");
1133        assert_eq!(e.ecosystem, "npm");
1134        assert_eq!(e.package_name, "lodash");
1135        assert_eq!(e.package_version, "4.17.21");
1136        assert_eq!(e.variant, "default");
1137        assert_eq!(e.policy_decision, "ALLOW");
1138        assert_eq!(
1139            e.verdict_id.as_deref(),
1140            Some("01936b8f-3c4a-7a12-9c00-0000000000aa")
1141        );
1142        assert_eq!(e.verdict_source, "ALLOWED_NO_FINDINGS");
1143        assert_eq!(e.policy_rule_id_matched, "rule-42");
1144        assert_eq!(e.risk_acceptance_status, "NONE");
1145        assert_eq!(e.reasoning, "ok");
1146        assert!(e.gcs_hit);
1147        assert_eq!(e.request_at, "2026-05-22T10:00:00Z");
1148        assert_eq!(e.verdict_at, "2026-05-22T10:00:01Z");
1149        assert_eq!(e.response_at, "2026-05-22T10:00:03Z");
1150        assert_eq!(e.app_version, "1.2.3");
1151    }
1152
1153    /// Backend-not-wired path: App emits the honesty signal + empty records.
1154    /// The CLI must decode `backend_status` (not silently coerce to empty
1155    /// via a `next_cursor` field that never existed on the wire).
1156    #[test]
1157    fn empty_audit_response_carries_backend_status() {
1158        let json = r#"{
1159            "window": {"since": null, "until": null},
1160            "records": [],
1161            "record_count": 0,
1162            "per_route": {},
1163            "backend_status": "not_wired"
1164        }"#;
1165        let resp: AuditResponse = serde_json::from_str(json).unwrap();
1166        assert!(resp.records.is_empty());
1167        assert_eq!(resp.record_count, 0);
1168        assert_eq!(resp.backend_status, "not_wired");
1169        assert!(resp.window.since.is_none());
1170        assert!(resp.window.until.is_none());
1171    }
1172
1173    #[test]
1174    fn policy_preview_request_omits_none_policy() {
1175        let req = PolicyPreviewRequest {
1176            packages: vec![PackageRef {
1177                ecosystem: "npm".to_string(),
1178                name: "lodash".to_string(),
1179                version: "4.17.21".to_string(),
1180            }],
1181            policy: None,
1182        };
1183        let json = serde_json::to_string(&req).unwrap();
1184        // None policy should not appear in serialized output
1185        assert!(!json.contains("policy"));
1186        assert!(json.contains("lodash"));
1187    }
1188
1189    #[test]
1190    fn policy_preview_request_emits_policy_when_some() {
1191        let req = PolicyPreviewRequest {
1192            packages: vec![],
1193            policy: Some(serde_json::json!({"rules": []})),
1194        };
1195        let json = serde_json::to_string(&req).unwrap();
1196        assert!(json.contains("\"policy\""));
1197        assert!(json.contains("\"rules\""));
1198    }
1199
1200    #[test]
1201    fn round_trips_via_json() {
1202        let v = Verdict {
1203            verdict_id: "01JBYK002".to_string(),
1204            verdict: "INSUFFICIENT_DATA".to_string(),
1205            source: "INSUFFICIENT_DATA".to_string(),
1206            stale_since_at: Some("2026-04-21T00:00:00Z".to_string()),
1207            staleness_reason: Some("upstream silent >30d".to_string()),
1208            ..Default::default()
1209        };
1210        let s = serde_json::to_string(&v).unwrap();
1211        let parsed: Verdict = serde_json::from_str(&s).unwrap();
1212        assert_eq!(parsed.verdict_id, "01JBYK002");
1213        assert_eq!(parsed.stale_since_at.as_deref(), Some("2026-04-21T00:00:00Z"));
1214    }
1215
1216    #[test]
1217    fn cleanlib_601_preserves_rich_data_recommended_version() {
1218        // The App's rich_data.recommended_version must survive deserialization into
1219        // the typed field (was silently dropped — no field for it). Extra rich_data
1220        // keys are tolerated (no deny_unknown_fields).
1221        let json = r#"{
1222            "verdict_id": "01JBYK601",
1223            "verdict": "VECTOR_VERDICT",
1224            "source": "VECTOR_VERDICT",
1225            "rich_data": { "recommended_version": "4.17.21", "some_other_key": 7 }
1226        }"#;
1227        let v: Verdict = serde_json::from_str(json).unwrap();
1228        assert_eq!(
1229            v.rich_data
1230                .as_ref()
1231                .and_then(|r| r.recommended_version.as_deref()),
1232            Some("4.17.21")
1233        );
1234
1235        // Absent rich_data → None (v1 back-compat, struct-level serde default).
1236        let v1: Verdict =
1237            serde_json::from_str(r#"{"verdict_id":"x","verdict":"ALLOWED_NO_FINDINGS","source":"x"}"#)
1238                .unwrap();
1239        assert!(v1.rich_data.is_none());
1240    }
1241
1242    // ─── Lane-2 M1 — severity + decision carrying ──────────────────────
1243
1244    #[test]
1245    fn verdict_round_trips_severity_and_decision() {
1246        let v = Verdict {
1247            verdict_id: "01JM1S001".to_string(),
1248            verdict: "VECTOR_VERDICT".to_string(),
1249            source: "VECTOR_VERDICT".to_string(),
1250            severity: Some("HIGH".to_string()),
1251            decision: Some("DENY".to_string()),
1252            ..Default::default()
1253        };
1254        let s = serde_json::to_string(&v).unwrap();
1255        let parsed: Verdict = serde_json::from_str(&s).unwrap();
1256        assert_eq!(parsed.severity.as_deref(), Some("HIGH"));
1257        assert_eq!(parsed.decision.as_deref(), Some("DENY"));
1258    }
1259
1260    #[test]
1261    fn verdict_tolerates_missing_severity_and_decision() {
1262        // Pre-M1 payload shape — no severity/decision fields. Must still parse
1263        // via serde-default tolerance per the struct's `#[serde(default)]`.
1264        let pre_m1_json = r#"{
1265            "verdict_id": "01JM1S002",
1266            "verdict": "ALLOWED_NO_FINDINGS",
1267            "source": "ALLOWED_NO_FINDINGS",
1268            "confidence": 0.95,
1269            "composite_score": 8,
1270            "reasoning": "",
1271            "similar_to": [],
1272            "evidence_gaps": [],
1273            "suggested_actions": []
1274        }"#;
1275        let v: Verdict = serde_json::from_str(pre_m1_json).expect("pre-M1 shape must still parse");
1276        assert!(v.severity.is_none());
1277        assert!(v.decision.is_none());
1278    }
1279
1280    #[test]
1281    fn verdict_decision_canonical_values_match_js_py_go() {
1282        // Lane-2 M1 acceptance: decision values match js/py/go SDK envelope.
1283        // Schema-locked set: ALLOW | WARN | DENY | RISK_ACCEPTANCE_REQUIRED.
1284        for d in ["ALLOW", "WARN", "DENY", "RISK_ACCEPTANCE_REQUIRED"] {
1285            let v = Verdict {
1286                decision: Some(d.to_string()),
1287                ..Default::default()
1288            };
1289            let s = serde_json::to_string(&v).unwrap();
1290            assert!(s.contains(&format!("\"decision\":\"{}\"", d)));
1291        }
1292    }
1293
1294    #[test]
1295    fn verdict_severity_canonical_values_match_cleanlib_core() {
1296        // Sister of `cleanlib-core::Severity` enum: NONE | LOW | MEDIUM | HIGH | CRITICAL.
1297        for sev in ["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"] {
1298            let v = Verdict {
1299                severity: Some(sev.to_string()),
1300                ..Default::default()
1301            };
1302            let s = serde_json::to_string(&v).unwrap();
1303            assert!(s.contains(&format!("\"severity\":\"{}\"", sev)));
1304        }
1305    }
1306
1307    // ─── CLEANLIB-743/813 · AuditEntry.request_id / verdict_id null-tolerance ──
1308    // (supersedes the narrower CLEANLIB-794 nil-UUID-string-only coverage below)
1309
1310    /// THE counterexample that was missing before this fix shipped: a literal
1311    /// JSON `null` on BOTH `request_id` and `verdict_id`, in the exact shape
1312    /// Test-mgr's live retroactive audit captured from real production
1313    /// (`invalid type: null, expected a string`) after the App's CLEANLIB-794
1314    /// fix started masking its nil-UUID placeholder to `null` at the wire
1315    /// boundary. FAILS on the pre-fix build (plain non-optional `String` on
1316    /// both fields — hard deserialize error, not a graceful default). PASSES
1317    /// post-fix: both fields deserialize to `None`, sibling fields untouched.
1318    #[test]
1319    fn cleanlib_743_audit_entry_tolerates_literal_null_request_id_and_verdict_id() {
1320        let json = r#"{"request_id": null,
1321                       "verdict_id": null,
1322                       "correlation_id": "01M2J6R8J5QDQXGE43GW6MMPTZ",
1323                       "ecosystem": "npm",
1324                       "package_name": "exceljs",
1325                       "package_version": "4.4.0",
1326                       "policy_decision": "ALLOW",
1327                       "policy_rule_id_matched": "allow-default",
1328                       "reasoning": "ALLOW_BY_ABSENCE -- no live vulnerability findings",
1329                       "request_at": "2026-08-31T05:52:40.003567Z"}"#;
1330        let e: AuditEntry = serde_json::from_str(json)
1331            .expect("a literal null request_id/verdict_id must not crash deserialize");
1332        assert!(e.request_id.is_none());
1333        assert!(e.verdict_id.is_none());
1334        // [SibFields] guard — the fix must not touch anything else.
1335        assert_eq!(e.correlation_id, "01M2J6R8J5QDQXGE43GW6MMPTZ");
1336        assert_eq!(e.package_name, "exceljs");
1337        assert_eq!(e.policy_decision, "ALLOW");
1338    }
1339
1340    /// The exact wire shape `cleanlib audit` (no flags) receives from real
1341    /// production per Test-mgr's 2026-09-15 repro: a full, otherwise-healthy
1342    /// `AuditResponse` envelope where every record's `request_id` /
1343    /// `verdict_id` is `null`. Exercises the whole response type, not just
1344    /// one record, and is the shape `cleanlib-cli::commands::audit::run`
1345    /// actually parses.
1346    #[test]
1347    fn cleanlib_743_audit_response_survives_null_ids_on_every_record() {
1348        let json = r#"{
1349            "window": {"since": null, "until": null},
1350            "records": [
1351                {"request_id": null, "verdict_id": null, "ecosystem": "npm",
1352                 "package_name": "lodash", "package_version": "4.17.15",
1353                 "policy_decision": "DENY"},
1354                {"request_id": null, "verdict_id": null, "ecosystem": "npm",
1355                 "package_name": "axios", "package_version": "1.6.0",
1356                 "policy_decision": "DENY"}
1357            ],
1358            "record_count": 2,
1359            "per_route": {},
1360            "backend_status": "wired"
1361        }"#;
1362        let resp: AuditResponse =
1363            serde_json::from_str(json).expect("500 real-shaped null-id records must parse");
1364        assert_eq!(resp.records.len(), 2);
1365        assert!(resp.records.iter().all(|r| r.request_id.is_none()));
1366        assert!(resp.records.iter().all(|r| r.verdict_id.is_none()));
1367    }
1368
1369    /// Counterexample: the App-side legacy placeholder must still NOT reach
1370    /// the SDK caller as a fake id (defense-in-depth for any code path that
1371    /// emits the nil-UUID STRING instead of the newer `null` mask). Every
1372    /// `request_id` / `verdict_id` equal to the nil-UUID string normalizes to
1373    /// `None` on deserialize — same outcome as a literal `null`.
1374    #[test]
1375    fn cleanlib_794_audit_entry_nil_uuid_string_normalizes_to_none() {
1376        let json = r#"{"request_id": "00000000-0000-0000-0000-000000000000",
1377                       "verdict_id": "00000000-0000-0000-0000-000000000000",
1378                       "ecosystem": "npm",
1379                       "package_name": "lodash",
1380                       "package_version": "4.17.21",
1381                       "policy_decision": "ALLOW"}"#;
1382        let e: AuditEntry = serde_json::from_str(json).unwrap();
1383        assert!(
1384            e.request_id.is_none(),
1385            "nil-UUID string must fold to None; pre-fix would surface the fake id verbatim"
1386        );
1387        assert!(e.verdict_id.is_none(), "same fold applies to verdict_id");
1388        // Sibling fields must NOT be touched — the normalizer scoped to
1389        // request_id/verdict_id only. [SibFields] guard.
1390        assert_eq!(e.package_name, "lodash");
1391        assert_eq!(e.ecosystem, "npm");
1392        assert_eq!(e.policy_decision, "ALLOW");
1393    }
1394
1395    /// A real ULID / UUID must pass through unchanged — the normalizer must
1396    /// not accidentally reject any non-nil value, for either field.
1397    #[test]
1398    fn cleanlib_794_audit_entry_real_ids_pass_through() {
1399        let json = r#"{"request_id": "01936b8f-3c4a-7a12-9c00-000000000001",
1400                       "verdict_id": "01936b8f-3c4a-7a12-9c00-0000000000aa",
1401                       "ecosystem": "npm",
1402                       "package_name": "lodash"}"#;
1403        let e: AuditEntry = serde_json::from_str(json).unwrap();
1404        assert_eq!(
1405            e.request_id.as_deref(),
1406            Some("01936b8f-3c4a-7a12-9c00-000000000001")
1407        );
1408        assert_eq!(
1409            e.verdict_id.as_deref(),
1410            Some("01936b8f-3c4a-7a12-9c00-0000000000aa")
1411        );
1412    }
1413
1414    /// A missing `request_id` / `verdict_id` key deserializes as `None` — same
1415    /// outcome as the nil-UUID fold and the literal-`null` case, so callers
1416    /// see one honest "absent" signal regardless of how the App represents it
1417    /// on the wire.
1418    #[test]
1419    fn cleanlib_794_audit_entry_missing_ids_are_none() {
1420        let json = r#"{"ecosystem": "npm", "package_name": "lodash"}"#;
1421        let e: AuditEntry = serde_json::from_str(json).unwrap();
1422        assert!(e.request_id.is_none());
1423        assert!(e.verdict_id.is_none());
1424    }
1425
1426    /// An empty string on the wire is folded into the same `None` outcome —
1427    /// there is now exactly one canonical "absent" representation
1428    /// (`.is_none()`), not three different ones a caller would have to check.
1429    #[test]
1430    fn cleanlib_794_audit_entry_empty_string_id_normalizes_to_none() {
1431        let json = r#"{"request_id": "", "verdict_id": "", "ecosystem": "npm", "package_name": "lodash"}"#;
1432        let e: AuditEntry = serde_json::from_str(json).unwrap();
1433        assert!(e.request_id.is_none());
1434        assert!(e.verdict_id.is_none());
1435    }
1436
1437    // ─── CLEANLIB-755/745: Remediation no longer silently drops server fields ──
1438
1439    /// COUNTEREXAMPLE per interview-discipline gate: the EXACT live production
1440    /// shape (npm/eslint@7.32.0 via the App's real `verbs::Remediation` wire
1441    /// shape). On the pre-fix 2-field struct (`target_version`+`command_hint`
1442    /// only), `screened_at` and `peer_compatibility_checked`/`_note` parse
1443    /// successfully (serde drops unknown keys) but vanish on re-serialize —
1444    /// exactly what `cleanlib verdict --output json` showed Test-mgr on
1445    /// 2026-09-15. This test FAILS on the pre-fix struct and PASSES only once
1446    /// every field round-trips.
1447    #[test]
1448    fn cleanlib_755_745_remediation_round_trips_every_server_field() {
1449        let json = r#"{
1450            "target_version": "9.26.0",
1451            "command_hint": "npm install eslint@9.26.0",
1452            "clears": [],
1453            "still_open": [],
1454            "compatibility": "major_bump",
1455            "screened_at": "2026-09-15T10:55:00Z",
1456            "peer_compatibility_checked": false,
1457            "peer_compatibility_note": "this target was selected from advisory data only..."
1458        }"#;
1459        let r: Remediation = serde_json::from_str(json).unwrap();
1460        assert_eq!(r.target_version, "9.26.0");
1461        assert_eq!(r.compatibility, Compatibility::MajorBump);
1462        assert_eq!(r.screened_at.as_deref(), Some("2026-09-15T10:55:00Z"));
1463        assert!(!r.peer_compatibility_checked);
1464        assert!(r.peer_compatibility_note.is_some());
1465
1466        // Re-serialize (what `--output json` does) must preserve every field.
1467        let out = serde_json::to_string(&r).unwrap();
1468        let back: serde_json::Value = serde_json::from_str(&out).unwrap();
1469        assert_eq!(back["screened_at"], "2026-09-15T10:55:00Z", "755: screened_at must survive re-serialize: {out}");
1470        assert_eq!(back["peer_compatibility_checked"], false, "745: peer_compatibility_checked must survive re-serialize: {out}");
1471        assert!(back["peer_compatibility_note"].is_string(), "745: peer_compatibility_note must survive re-serialize: {out}");
1472        assert_eq!(back["compatibility"], "major_bump");
1473    }
1474
1475    /// Absent-field siblings (server omits `screened_at`/`peer_compatibility_note`
1476    /// because nothing was consulted, or a pre-755/745 App build) must stay
1477    /// absent — omit, never fabricate — and must NOT fail parsing of the rest
1478    /// of the struct.
1479    #[test]
1480    fn cleanlib_755_745_remediation_tolerates_absent_optional_fields() {
1481        let json = r#"{"target_version": "1.0.0", "command_hint": "npm install x@1.0.0"}"#;
1482        let r: Remediation = serde_json::from_str(json).unwrap();
1483        assert_eq!(r.screened_at, None);
1484        assert!(!r.peer_compatibility_checked);
1485        assert_eq!(r.peer_compatibility_note, None);
1486        assert_eq!(r.compatibility, Compatibility::Unknown, "absent compatibility defaults to Unknown, not SameMajor");
1487
1488        let out = serde_json::to_string(&r).unwrap();
1489        assert!(!out.contains("screened_at"), "absent screened_at must be omitted, not null: {out}");
1490        assert!(!out.contains("peer_compatibility_note"), "absent note must be omitted, not null: {out}");
1491    }
1492
1493    /// `Default::default()` (used by every existing struct-literal test
1494    /// construction across the workspace) must yield the same safe defaults —
1495    /// `Unknown` compatibility, `false` peer_compatibility_checked, absent
1496    /// optionals — so old tests that only set target_version/command_hint
1497    /// keep their original meaning.
1498    #[test]
1499    fn cleanlib_755_745_remediation_default_is_the_safe_unset_state() {
1500        let r = Remediation {
1501            target_version: "1.0.0".to_string(),
1502            command_hint: "x".to_string(),
1503            ..Default::default()
1504        };
1505        assert_eq!(r.compatibility, Compatibility::Unknown);
1506        assert!(!r.peer_compatibility_checked);
1507        assert_eq!(r.screened_at, None);
1508        assert_eq!(r.peer_compatibility_note, None);
1509    }
1510}