cleanlib-client 0.1.1

HTTP client SDK for the CleanLibrary verdict API — VerdictEnvelopeV1 types, derive_status logic, transport, config, and risk-acceptance YAML emitter shared between cleanlib-cli and other CleanLibrary consumers.
Documentation
//! `VerdictEnvelopeV1` + `ReasonCode` — schema-locked mirror of
//! `@cleanstart/cleanlib-sdk@0.4.1` (tarball sha-1
//! `b5f00c160907a6ea1f490f14a9bda6f6de34b8b6`).
//!
//! Sister of:
//! - sdk-js  `dist/reason-codes.js` + `dist/verdict-envelope.schema.json`
//! - sdk-py  `cleanlib_sdk/reason_codes.py`
//! - sdk-go  `reason_codes.go`
//!
//! Drift between any of the four SDK consumers is a CI failure;
//! `cleanlib-contract-fixtures` v1.0.0 verifies byte-identical
//! `(status, reason_code)` across all 4 implementations.
//!
//! Freshness-precedence rule (ratified 2026-05-28; binding via App dispatch §4
//! + Client dispatch §2.3): when the substance-driving signal's
//! `availability == "degraded_stale"`, the substance-derived status tier is
//! PRESERVED and the `reason_code` OVERRIDES to `VERDICT_DEGRADED_STALE`.
//! Server-side `VERDICT_DEGRADED_STALE` is architecturally distinct from the
//! client-side `LIVE_DEGRADED` cache-fallback state (extension status bar +
//! Cli7 offline mode).

use serde::{Deserialize, Serialize};

/// Tri-state envelope status tier — sister of sdk-js Literal type
/// `'ALLOW' | 'WARN' | 'DENY'`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub enum Status {
    #[serde(rename = "ALLOW")]
    Allow,
    #[serde(rename = "WARN")]
    Warn,
    #[serde(rename = "DENY")]
    Deny,
}

impl Status {
    /// Canonical wire-format string — what App emits + what every other SDK
    /// asserts on.
    pub fn as_str(&self) -> &'static str {
        match self {
            Status::Allow => "ALLOW",
            Status::Warn => "WARN",
            Status::Deny => "DENY",
        }
    }
}

impl std::fmt::Display for Status {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Canonical 15-value `ReasonCode` registry — Rust mirror of sdk-js v0.4.1
/// `dist/reason-codes.js`. Drift = CI failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub enum ReasonCode {
    // ─── Status outcomes (verdict-tier reasons) ──────────────────────────
    #[serde(rename = "VERDICT_CLEAN")]
    VerdictClean,
    #[serde(rename = "VERDICT_RECOMMENDED_VERSION_NEWER")]
    VerdictRecommendedVersionNewer,
    #[serde(rename = "VERDICT_ABANDONED")]
    VerdictAbandoned,
    #[serde(rename = "VERDICT_LOW_TRUST")]
    VerdictLowTrust,
    /// Server-side substrate freshness — substance-derived tier preserved,
    /// reason overridden to surface staleness. Distinct from client-side
    /// `LIVE_DEGRADED` cache-fallback (extension status bar; Cli7 offline mode).
    #[serde(rename = "VERDICT_DEGRADED_STALE")]
    VerdictDegradedStale,
    #[serde(rename = "VERDICT_HAS_REMEDIATION")]
    VerdictHasRemediation,
    #[serde(rename = "VERDICT_KEV_LISTED")]
    VerdictKevListed,
    #[serde(rename = "VERDICT_EXPLOITATION_CRITICAL")]
    VerdictExploitationCritical,
    #[serde(rename = "VERDICT_OBFUSCATED")]
    VerdictObfuscated,
    #[serde(rename = "VERDICT_DENY_LIST")]
    VerdictDenyList,

    // ─── Client-transport reasons ────────────────────────────────────────
    #[serde(rename = "CLIENT_NETWORK_UNREACHABLE")]
    ClientNetworkUnreachable,
    #[serde(rename = "CLIENT_AUTH_FAILED")]
    ClientAuthFailed,
    #[serde(rename = "CLIENT_BEARER_MISSING")]
    ClientBearerMissing,
    #[serde(rename = "CLIENT_RATE_LIMITED")]
    ClientRateLimited,

    // ─── Domain — 404 from /api/v1/remediation/:eco/:name ───────────────
    #[serde(rename = "REMEDIATION_NOT_FOUND")]
    RemediationNotFound,
}

impl ReasonCode {
    /// Canonical wire-format string — what App emits + what every other SDK
    /// asserts on.
    pub fn as_str(&self) -> &'static str {
        match self {
            ReasonCode::VerdictClean => "VERDICT_CLEAN",
            ReasonCode::VerdictRecommendedVersionNewer => "VERDICT_RECOMMENDED_VERSION_NEWER",
            ReasonCode::VerdictAbandoned => "VERDICT_ABANDONED",
            ReasonCode::VerdictLowTrust => "VERDICT_LOW_TRUST",
            ReasonCode::VerdictDegradedStale => "VERDICT_DEGRADED_STALE",
            ReasonCode::VerdictHasRemediation => "VERDICT_HAS_REMEDIATION",
            ReasonCode::VerdictKevListed => "VERDICT_KEV_LISTED",
            ReasonCode::VerdictExploitationCritical => "VERDICT_EXPLOITATION_CRITICAL",
            ReasonCode::VerdictObfuscated => "VERDICT_OBFUSCATED",
            ReasonCode::VerdictDenyList => "VERDICT_DENY_LIST",
            ReasonCode::ClientNetworkUnreachable => "CLIENT_NETWORK_UNREACHABLE",
            ReasonCode::ClientAuthFailed => "CLIENT_AUTH_FAILED",
            ReasonCode::ClientBearerMissing => "CLIENT_BEARER_MISSING",
            ReasonCode::ClientRateLimited => "CLIENT_RATE_LIMITED",
            ReasonCode::RemediationNotFound => "REMEDIATION_NOT_FOUND",
        }
    }
}

impl std::fmt::Display for ReasonCode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// All 15 canonical reason-code values — consumed by drift-check CI.
pub const ALL_REASON_CODES: &[ReasonCode] = &[
    ReasonCode::VerdictClean,
    ReasonCode::VerdictRecommendedVersionNewer,
    ReasonCode::VerdictAbandoned,
    ReasonCode::VerdictLowTrust,
    ReasonCode::VerdictDegradedStale,
    ReasonCode::VerdictHasRemediation,
    ReasonCode::VerdictKevListed,
    ReasonCode::VerdictExploitationCritical,
    ReasonCode::VerdictObfuscated,
    ReasonCode::VerdictDenyList,
    ReasonCode::ClientNetworkUnreachable,
    ReasonCode::ClientAuthFailed,
    ReasonCode::ClientBearerMissing,
    ReasonCode::ClientRateLimited,
    ReasonCode::RemediationNotFound,
];

/// `VerdictEnvelopeV1` — parsed shape of the `verdict-envelope.v1.json`
/// schema. Top-level fields are required; rich sub-objects are sparse and
/// `#[serde(default)]`-tolerant so the SDK can consume partial responses
/// during cycle-N spec evolution without forcing a recompile.
///
/// Sister of:
/// - sdk-js  `VerdictEnvelopeV1Schema` (zod)
/// - sdk-py  no struct (dict[str, Any] in Python)
/// - sdk-go  `map[string]any` (loose) — but Rust gets a typed struct.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct VerdictEnvelopeV1 {
    pub status: String,
    pub reason_code: String,
    pub human_message: String,
    pub as_of: String,

    #[serde(default)]
    pub rich_data: Option<serde_json::Value>,
    #[serde(default)]
    pub remediation: Option<serde_json::Value>,
    #[serde(default)]
    pub exploitability: Option<serde_json::Value>,
    #[serde(default)]
    pub availability: Option<serde_json::Value>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_minimal_envelope() {
        let json = r#"{
            "status": "ALLOW",
            "reason_code": "VERDICT_CLEAN",
            "human_message": "ok",
            "as_of": "2026-05-28"
        }"#;
        let env: VerdictEnvelopeV1 = serde_json::from_str(json).unwrap();
        assert_eq!(env.status, "ALLOW");
        assert_eq!(env.reason_code, "VERDICT_CLEAN");
        assert!(env.rich_data.is_none());
        assert!(env.remediation.is_none());
        assert!(env.exploitability.is_none());
        assert!(env.availability.is_none());
    }

    #[test]
    fn all_15_reason_codes_present() {
        // Drift-check sister of sdk-py ALL_REASON_CODES + sdk-go AllReasonCodes.
        assert_eq!(ALL_REASON_CODES.len(), 15);
    }

    #[test]
    fn reason_code_string_roundtrip() {
        for rc in ALL_REASON_CODES {
            let s = serde_json::to_string(rc).unwrap();
            let back: ReasonCode = serde_json::from_str(&s).unwrap();
            assert_eq!(&back, rc);
            // as_str() matches serde wire format (quoted JSON string).
            assert_eq!(format!("\"{}\"", rc.as_str()), s);
        }
    }

    #[test]
    fn status_string_roundtrip() {
        for st in [Status::Allow, Status::Warn, Status::Deny] {
            let s = serde_json::to_string(&st).unwrap();
            let back: Status = serde_json::from_str(&s).unwrap();
            assert_eq!(back, st);
            assert_eq!(format!("\"{}\"", st.as_str()), s);
        }
    }
}