1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "camelCase")]
25pub struct ComplianceScoreWindow {
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub since: Option<u64>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub until: Option<u64>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "camelCase")]
37pub struct ComplianceScoreRequest {
38 pub agent_id: String,
40 #[serde(default)]
42 pub window: ComplianceScoreWindow,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub config: Option<ComplianceScoreConfig>,
46}
47
48#[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#[derive(Debug, Clone)]
65pub struct ComplianceSourceResult {
66 pub report: ComplianceReport,
69 pub inputs: ComplianceScoreInputs,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum ComplianceScoreError {
76 BadRequest(String),
78 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
116pub trait ComplianceSource: Send + Sync {
120 fn fetch(
121 &self,
122 agent_id: &str,
123 window: &ComplianceScoreWindow,
124 ) -> Result<ComplianceSourceResult, ComplianceScoreError>;
125}
126
127pub 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 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}