Skip to main content

chio_market/
provider.rs

1//! Liability provider registry types and resolution reports.
2
3use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use crate::capability::scope::MonetaryAmount;
8use crate::receipt::lineage::SignedExportEnvelope;
9
10use crate::{bounded_market_query_limit, MAX_LIABILITY_PROVIDER_LIST_LIMIT};
11
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
13#[serde(rename_all = "snake_case")]
14pub enum LiabilityProviderType {
15    AdmittedCarrier,
16    SurplusLine,
17    Captive,
18    RiskPool,
19}
20
21#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
22#[serde(rename_all = "snake_case")]
23pub enum LiabilityCoverageClass {
24    ToolExecution,
25    DataBreach,
26    FinancialLoss,
27    ProfessionalLiability,
28    RegulatoryResponse,
29}
30
31#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
32#[serde(rename_all = "snake_case")]
33pub enum LiabilityEvidenceRequirement {
34    BehavioralFeed,
35    UnderwritingDecision,
36    CreditProviderRiskPackage,
37    RuntimeAttestationAppraisal,
38    CertificationArtifact,
39    CreditBond,
40    AuthorizationReviewPack,
41}
42
43#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "snake_case")]
45pub enum LiabilityProviderLifecycleState {
46    Active,
47    Suspended,
48    Superseded,
49    Retired,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53#[serde(rename_all = "camelCase")]
54pub struct LiabilityProviderProvenance {
55    pub configured_by: String,
56    pub configured_at: u64,
57    pub source_ref: String,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub change_reason: Option<String>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
63#[serde(rename_all = "camelCase")]
64pub struct LiabilityJurisdictionPolicy {
65    pub jurisdiction: String,
66    pub coverage_classes: Vec<LiabilityCoverageClass>,
67    pub supported_currencies: Vec<String>,
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub required_evidence: Vec<LiabilityEvidenceRequirement>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub max_coverage_amount: Option<MonetaryAmount>,
72    pub claims_supported: bool,
73    pub quote_ttl_seconds: u64,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub notes: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(rename_all = "camelCase")]
80pub struct LiabilityProviderSupportBoundary {
81    pub curated_registry_only: bool,
82    pub automatic_trust_admission: bool,
83    pub permissionless_federation_supported: bool,
84    pub bound_coverage_supported: bool,
85}
86
87impl Default for LiabilityProviderSupportBoundary {
88    fn default() -> Self {
89        Self {
90            curated_registry_only: true,
91            automatic_trust_admission: false,
92            permissionless_federation_supported: false,
93            bound_coverage_supported: false,
94        }
95    }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99#[serde(rename_all = "camelCase")]
100pub struct LiabilityProviderReport {
101    pub schema: String,
102    pub provider_id: String,
103    pub display_name: String,
104    pub provider_type: LiabilityProviderType,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub provider_url: Option<String>,
107    pub lifecycle_state: LiabilityProviderLifecycleState,
108    pub support_boundary: LiabilityProviderSupportBoundary,
109    pub policies: Vec<LiabilityJurisdictionPolicy>,
110    pub provenance: LiabilityProviderProvenance,
111}
112
113impl LiabilityProviderReport {
114    pub fn validate(&self) -> Result<(), String> {
115        if self.provider_id.trim().is_empty() {
116            return Err("provider_id must not be empty".to_string());
117        }
118        if self.display_name.trim().is_empty() {
119            return Err("display_name must not be empty".to_string());
120        }
121        if self.provenance.configured_by.trim().is_empty() {
122            return Err("provenance.configured_by must not be empty".to_string());
123        }
124        if self.provenance.source_ref.trim().is_empty() {
125            return Err("provenance.source_ref must not be empty".to_string());
126        }
127        if let Some(provider_url) = self.provider_url.as_deref() {
128            if !(provider_url.starts_with("http://") || provider_url.starts_with("https://")) {
129                return Err("provider_url must start with http:// or https://".to_string());
130            }
131        }
132        if self.policies.is_empty() {
133            return Err("providers require at least one jurisdiction policy".to_string());
134        }
135
136        let mut seen_jurisdictions = BTreeSet::new();
137        for policy in &self.policies {
138            if policy.jurisdiction.trim().is_empty() {
139                return Err("jurisdiction policies require a non-empty jurisdiction".to_string());
140            }
141            let normalized_jurisdiction = policy.jurisdiction.trim().to_ascii_lowercase();
142            if !seen_jurisdictions.insert(normalized_jurisdiction) {
143                return Err(format!(
144                    "provider `{}` defines duplicate jurisdiction policy `{}`",
145                    self.provider_id, policy.jurisdiction
146                ));
147            }
148            if policy.coverage_classes.is_empty() {
149                return Err(format!(
150                    "jurisdiction policy `{}` requires at least one coverage class",
151                    policy.jurisdiction
152                ));
153            }
154            if policy.supported_currencies.is_empty() {
155                return Err(format!(
156                    "jurisdiction policy `{}` requires at least one supported currency",
157                    policy.jurisdiction
158                ));
159            }
160            if policy.quote_ttl_seconds == 0 {
161                return Err(format!(
162                    "jurisdiction policy `{}` requires quote_ttl_seconds greater than zero",
163                    policy.jurisdiction
164                ));
165            }
166            let mut seen_coverage = BTreeSet::new();
167            for coverage_class in &policy.coverage_classes {
168                if !seen_coverage.insert(*coverage_class) {
169                    return Err(format!(
170                        "jurisdiction policy `{}` defines duplicate coverage class `{:?}`",
171                        policy.jurisdiction, coverage_class
172                    ));
173                }
174            }
175            let mut seen_currencies = BTreeSet::new();
176            for currency in &policy.supported_currencies {
177                let normalized_currency = currency.trim().to_ascii_uppercase();
178                if normalized_currency.len() != 3
179                    || !normalized_currency
180                        .chars()
181                        .all(|character| character.is_ascii_uppercase())
182                {
183                    return Err(format!(
184                        "jurisdiction policy `{}` contains invalid currency `{}`",
185                        policy.jurisdiction, currency
186                    ));
187                }
188                if !seen_currencies.insert(normalized_currency) {
189                    return Err(format!(
190                        "jurisdiction policy `{}` contains duplicate currency `{}`",
191                        policy.jurisdiction, currency
192                    ));
193                }
194            }
195        }
196
197        Ok(())
198    }
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
202#[serde(rename_all = "camelCase")]
203pub struct LiabilityProviderArtifact {
204    pub schema: String,
205    pub provider_record_id: String,
206    pub issued_at: u64,
207    pub lifecycle_state: LiabilityProviderLifecycleState,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub supersedes_provider_record_id: Option<String>,
210    pub report: LiabilityProviderReport,
211}
212
213pub type SignedLiabilityProvider = SignedExportEnvelope<LiabilityProviderArtifact>;
214
215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
216#[serde(rename_all = "camelCase")]
217pub struct LiabilityProviderListQuery {
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub provider_id: Option<String>,
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub jurisdiction: Option<String>,
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub coverage_class: Option<LiabilityCoverageClass>,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub currency: Option<String>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub lifecycle_state: Option<LiabilityProviderLifecycleState>,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub limit: Option<usize>,
230}
231
232impl Default for LiabilityProviderListQuery {
233    fn default() -> Self {
234        Self {
235            provider_id: None,
236            jurisdiction: None,
237            coverage_class: None,
238            currency: None,
239            lifecycle_state: None,
240            limit: Some(50),
241        }
242    }
243}
244
245impl LiabilityProviderListQuery {
246    #[must_use]
247    pub fn limit_or_default(&self) -> usize {
248        bounded_market_query_limit(self.limit, MAX_LIABILITY_PROVIDER_LIST_LIMIT)
249    }
250
251    #[must_use]
252    pub fn normalized(&self) -> Self {
253        let mut normalized = self.clone();
254        normalized.limit = Some(self.limit_or_default());
255        normalized.currency = self
256            .currency
257            .as_ref()
258            .map(|currency| currency.trim().to_ascii_uppercase());
259        normalized.jurisdiction = self
260            .jurisdiction
261            .as_ref()
262            .map(|jurisdiction| jurisdiction.trim().to_ascii_lowercase());
263        normalized
264    }
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
268#[serde(rename_all = "camelCase")]
269pub struct LiabilityProviderRow {
270    pub provider: SignedLiabilityProvider,
271    pub lifecycle_state: LiabilityProviderLifecycleState,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub superseded_by_provider_record_id: Option<String>,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
277#[serde(rename_all = "camelCase")]
278pub struct LiabilityProviderListSummary {
279    pub matching_providers: u64,
280    pub returned_providers: u64,
281    pub active_providers: u64,
282    pub suspended_providers: u64,
283    pub superseded_providers: u64,
284    pub retired_providers: u64,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
288#[serde(rename_all = "camelCase")]
289pub struct LiabilityProviderListReport {
290    pub schema: String,
291    pub generated_at: u64,
292    pub query: LiabilityProviderListQuery,
293    pub summary: LiabilityProviderListSummary,
294    pub providers: Vec<LiabilityProviderRow>,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
298#[serde(rename_all = "camelCase")]
299pub struct LiabilityProviderResolutionQuery {
300    pub provider_id: String,
301    pub jurisdiction: String,
302    pub coverage_class: LiabilityCoverageClass,
303    pub currency: String,
304}
305
306impl LiabilityProviderResolutionQuery {
307    pub fn validate(&self) -> Result<(), String> {
308        if self.provider_id.trim().is_empty() {
309            return Err("provider_id must not be empty".to_string());
310        }
311        if self.jurisdiction.trim().is_empty() {
312            return Err("jurisdiction must not be empty".to_string());
313        }
314        let currency = self.currency.trim().to_ascii_uppercase();
315        if currency.len() != 3
316            || !currency
317                .chars()
318                .all(|character| character.is_ascii_uppercase())
319        {
320            return Err("currency must be a three-letter uppercase ISO-style code".to_string());
321        }
322        Ok(())
323    }
324
325    #[must_use]
326    pub fn normalized(&self) -> Self {
327        Self {
328            provider_id: self.provider_id.trim().to_string(),
329            jurisdiction: self.jurisdiction.trim().to_ascii_lowercase(),
330            coverage_class: self.coverage_class,
331            currency: self.currency.trim().to_ascii_uppercase(),
332        }
333    }
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
337#[serde(rename_all = "camelCase")]
338pub struct LiabilityProviderResolutionReport {
339    pub schema: String,
340    pub generated_at: u64,
341    pub query: LiabilityProviderResolutionQuery,
342    pub provider: SignedLiabilityProvider,
343    pub matched_policy: LiabilityJurisdictionPolicy,
344    pub support_boundary: LiabilityProviderSupportBoundary,
345}