1use 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}