Skip to main content

chio_http_core/
compliance.rs

1//! Phase 19.1 -- HTTP handler for `POST /compliance/score`.
2//!
3//! The handler is substrate-agnostic: adapters feed in raw request
4//! bytes, the handler parses them into a [`ComplianceScoreRequest`],
5//! hands the filtered query to a pluggable [`ComplianceSource`], and
6//! returns a [`ComplianceScoreResponse`] carrying the score and the
7//! per-factor breakdown.
8//!
9//! The kernel never signs the response itself -- `chio-kernel` already
10//! guarantees receipts are authenticated. Operators who need a signed
11//! report compose this handler with the regulatory API.
12
13use std::sync::Arc;
14
15use chio_kernel::compliance_score::{
16    compliance_score, ComplianceScore, ComplianceScoreConfig, ComplianceScoreInputs,
17};
18use chio_kernel::operator_report::ComplianceReport;
19use chio_kernel::{ChioKernel, UnderwritingComplianceEvidence};
20use serde::{Deserialize, Serialize};
21
22/// Time window over which to compute the score.
23#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "camelCase")]
25pub struct ComplianceScoreWindow {
26    /// Inclusive lower bound (unix seconds).
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub since: Option<u64>,
29    /// Inclusive upper bound (unix seconds).
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub until: Option<u64>,
32}
33
34/// Request body for `POST /compliance/score`.
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "camelCase")]
37pub struct ComplianceScoreRequest {
38    /// Agent subject to score.
39    pub agent_id: String,
40    /// Time window bounds. All fields optional.
41    #[serde(default)]
42    pub window: ComplianceScoreWindow,
43    /// Optional overrides for the scoring config.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub config: Option<ComplianceScoreConfig>,
46}
47
48/// Response body for `POST /compliance/score`.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct ComplianceScoreResponse {
52    #[serde(flatten)]
53    pub score: ComplianceScore,
54}
55
56impl ComplianceScoreResponse {
57    #[must_use]
58    pub fn underwriting_evidence(&self) -> UnderwritingComplianceEvidence {
59        self.score.as_underwriting_evidence()
60    }
61}
62
63/// Inputs the handler requires from the backing store.
64#[derive(Debug, Clone)]
65pub struct ComplianceSourceResult {
66    /// Compliance report for the window (lineage + checkpoint
67    /// coverage).
68    pub report: ComplianceReport,
69    /// Ambient inputs the report doesn't carry.
70    pub inputs: ComplianceScoreInputs,
71}
72
73/// Error shape for [`handle_compliance_score`].
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum ComplianceScoreError {
76    /// Malformed request body.
77    BadRequest(String),
78    /// The backing store was unavailable.
79    StoreUnavailable(String),
80}
81
82impl ComplianceScoreError {
83    #[must_use]
84    pub fn status(&self) -> u16 {
85        match self {
86            Self::BadRequest(_) => 400,
87            Self::StoreUnavailable(_) => 503,
88        }
89    }
90
91    #[must_use]
92    pub fn code(&self) -> &'static str {
93        match self {
94            Self::BadRequest(_) => "bad_request",
95            Self::StoreUnavailable(_) => "store_unavailable",
96        }
97    }
98
99    #[must_use]
100    pub fn message(&self) -> String {
101        match self {
102            Self::BadRequest(m) => m.clone(),
103            Self::StoreUnavailable(m) => m.clone(),
104        }
105    }
106
107    #[must_use]
108    pub fn body(&self) -> serde_json::Value {
109        serde_json::json!({
110            "error": self.code(),
111            "message": self.message(),
112        })
113    }
114}
115
116/// Pluggable compliance source. Substrate adapters plug in an
117/// chio-store-sqlite-backed implementation; the handler itself stays
118/// decoupled from storage.
119pub trait ComplianceSource: Send + Sync {
120    fn fetch(
121        &self,
122        agent_id: &str,
123        window: &ComplianceScoreWindow,
124    ) -> Result<ComplianceSourceResult, ComplianceScoreError>;
125}
126
127/// Handler for `POST /compliance/score`.
128pub fn handle_compliance_score(
129    _kernel: &Arc<ChioKernel>,
130    source: &dyn ComplianceSource,
131    body: &[u8],
132    now: u64,
133) -> Result<ComplianceScoreResponse, ComplianceScoreError> {
134    let parsed: ComplianceScoreRequest = serde_json::from_slice(body).map_err(|e| {
135        ComplianceScoreError::BadRequest(format!("invalid compliance/score body: {e}"))
136    })?;
137    if parsed.agent_id.trim().is_empty() {
138        return Err(ComplianceScoreError::BadRequest(
139            "agent_id must not be empty".to_string(),
140        ));
141    }
142    let data = source.fetch(&parsed.agent_id, &parsed.window)?;
143    let config = parsed.config.unwrap_or_default();
144    let score = compliance_score(&data.report, &data.inputs, &config, &parsed.agent_id, now);
145    Ok(ComplianceScoreResponse { score })
146}
147
148#[cfg(test)]
149#[allow(clippy::unwrap_used, clippy::expect_used)]
150mod tests {
151    use super::*;
152    use chio_kernel::evidence_export::{EvidenceChildReceiptScope, EvidenceExportQuery};
153
154    fn clean_report() -> ComplianceReport {
155        ComplianceReport {
156            matching_receipts: 1000,
157            evidence_ready_receipts: 1000,
158            uncheckpointed_receipts: 0,
159            checkpoint_coverage_rate: Some(1.0),
160            lineage_covered_receipts: 1000,
161            lineage_gap_receipts: 0,
162            lineage_coverage_rate: Some(1.0),
163            pending_settlement_receipts: 0,
164            failed_settlement_receipts: 0,
165            direct_evidence_export_supported: true,
166            child_receipt_scope: EvidenceChildReceiptScope::FullQueryWindow,
167            proofs_complete: true,
168            export_query: EvidenceExportQuery::default(),
169            export_scope_note: None,
170        }
171    }
172
173    struct FixedSource(ComplianceSourceResult);
174    impl ComplianceSource for FixedSource {
175        fn fetch(
176            &self,
177            _agent_id: &str,
178            _window: &ComplianceScoreWindow,
179        ) -> Result<ComplianceSourceResult, ComplianceScoreError> {
180            Ok(self.0.clone())
181        }
182    }
183
184    #[test]
185    fn empty_agent_id_is_rejected() {
186        let source = FixedSource(ComplianceSourceResult {
187            report: clean_report(),
188            inputs: ComplianceScoreInputs::default(),
189        });
190        let body = serde_json::to_vec(&ComplianceScoreRequest {
191            agent_id: "".to_string(),
192            window: ComplianceScoreWindow::default(),
193            config: None,
194        })
195        .unwrap();
196        // We build a dummy kernel via ChioKernel::new with the
197        // simplest possible config.
198        let keypair = chio_core_types::crypto::Keypair::generate();
199        let kernel = Arc::new(ChioKernel::new(chio_kernel::KernelConfig {
200            keypair,
201            ca_public_keys: vec![],
202            max_delegation_depth: 1,
203            policy_hash: "ph".to_string(),
204            allow_sampling: false,
205            allow_sampling_tool_use: false,
206            allow_elicitation: false,
207            max_stream_duration_secs: chio_kernel::DEFAULT_MAX_STREAM_DURATION_SECS,
208            max_stream_total_bytes: chio_kernel::DEFAULT_MAX_STREAM_TOTAL_BYTES,
209            require_web3_evidence: false,
210            checkpoint_batch_size: chio_kernel::DEFAULT_CHECKPOINT_BATCH_SIZE,
211            retention_config: None,
212        }));
213        let err = handle_compliance_score(&kernel, &source, &body, 0).unwrap_err();
214        assert_eq!(err.status(), 400);
215    }
216
217    #[test]
218    fn returns_clean_score_for_clean_inputs() {
219        let result = ComplianceSourceResult {
220            report: clean_report(),
221            inputs: ComplianceScoreInputs::new(1000, 0, 1, 0, 1000, 0, Some(0)),
222        };
223        let source = FixedSource(result);
224        let body = serde_json::to_vec(&ComplianceScoreRequest {
225            agent_id: "a1".to_string(),
226            window: ComplianceScoreWindow::default(),
227            config: None,
228        })
229        .unwrap();
230        let keypair = chio_core_types::crypto::Keypair::generate();
231        let kernel = Arc::new(ChioKernel::new(chio_kernel::KernelConfig {
232            keypair,
233            ca_public_keys: vec![],
234            max_delegation_depth: 1,
235            policy_hash: "ph".to_string(),
236            allow_sampling: false,
237            allow_sampling_tool_use: false,
238            allow_elicitation: false,
239            max_stream_duration_secs: chio_kernel::DEFAULT_MAX_STREAM_DURATION_SECS,
240            max_stream_total_bytes: chio_kernel::DEFAULT_MAX_STREAM_TOTAL_BYTES,
241            require_web3_evidence: false,
242            checkpoint_batch_size: chio_kernel::DEFAULT_CHECKPOINT_BATCH_SIZE,
243            retention_config: None,
244        }));
245        let resp = handle_compliance_score(&kernel, &source, &body, 0).unwrap();
246        assert!(resp.score.score > 900);
247    }
248}