Skip to main content

chio_market/
claim.rs

1//! Liability claim package, response, dispute, and adjudication artifacts.
2
3use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use crate::capability::scope::MonetaryAmount;
8use crate::credit::{SignedCreditBond, SignedCreditLossLifecycle, SignedExposureLedgerReport};
9use crate::receipt::lineage::SignedExportEnvelope;
10
11use crate::{validate_positive_money, verify_signed_artifact, SignedLiabilityBoundCoverage};
12
13#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "snake_case")]
15pub enum LiabilityClaimEvidenceKind {
16    BoundCoverage,
17    ExposureLedger,
18    CreditBond,
19    CreditLossLifecycle,
20    Receipt,
21    ClaimResponse,
22    ClaimDispute,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "camelCase")]
27pub struct LiabilityClaimEvidenceReference {
28    pub kind: LiabilityClaimEvidenceKind,
29    pub reference_id: String,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub observed_at: Option<u64>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub locator: Option<String>,
34}
35
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum LiabilityClaimResponseDisposition {
39    Acknowledged,
40    Accepted,
41    Denied,
42}
43
44#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "snake_case")]
46pub enum LiabilityClaimAdjudicationOutcome {
47    ClaimUpheld,
48    ProviderUpheld,
49    PartialSettlement,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53#[serde(rename_all = "camelCase")]
54pub struct LiabilityClaimPackageArtifact {
55    pub schema: String,
56    pub claim_id: String,
57    pub issued_at: u64,
58    pub bound_coverage: SignedLiabilityBoundCoverage,
59    pub exposure: SignedExposureLedgerReport,
60    pub bond: SignedCreditBond,
61    pub loss_event: SignedCreditLossLifecycle,
62    pub claimant: String,
63    pub claim_event_at: u64,
64    pub claim_amount: MonetaryAmount,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub claim_ref: Option<String>,
67    pub narrative: String,
68    pub receipt_ids: Vec<String>,
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub evidence_refs: Vec<LiabilityClaimEvidenceReference>,
71}
72
73impl LiabilityClaimPackageArtifact {
74    pub fn validate(&self) -> Result<(), String> {
75        verify_signed_artifact(&self.bound_coverage, "claim package bound_coverage")?;
76        verify_signed_artifact(&self.exposure, "claim package exposure")?;
77        verify_signed_artifact(&self.bond, "claim package bond")?;
78        verify_signed_artifact(&self.loss_event, "claim package loss_event")?;
79        if self.claimant.trim().is_empty() {
80            return Err("claim packages require a non-empty claimant".to_string());
81        }
82        if self.narrative.trim().is_empty() {
83            return Err("claim packages require a non-empty narrative".to_string());
84        }
85        if self.receipt_ids.is_empty() {
86            return Err("claim packages require at least one receipt reference".to_string());
87        }
88        let mut deduped_receipts = BTreeSet::new();
89        for receipt_id in &self.receipt_ids {
90            if receipt_id.trim().is_empty() {
91                return Err("claim receipt references must be non-empty".to_string());
92            }
93            if !deduped_receipts.insert(receipt_id.trim().to_string()) {
94                return Err("claim receipt references must be unique".to_string());
95            }
96        }
97        validate_positive_money(&self.claim_amount, "claim_amount")?;
98        let coverage = &self.bound_coverage.body.coverage_amount;
99        if self.claim_amount.currency != coverage.currency {
100            return Err("claim_amount currency must match bound coverage currency".to_string());
101        }
102        if self.claim_amount.units > coverage.units {
103            return Err("claim_amount cannot exceed bound coverage amount".to_string());
104        }
105        if self.claim_event_at < self.bound_coverage.body.effective_from
106            || self.claim_event_at > self.bound_coverage.body.effective_until
107        {
108            return Err(
109                "claim_event_at must fall within the bound coverage effective window".to_string(),
110            );
111        }
112        if self.exposure.body.summary.mixed_currency_book {
113            return Err(
114                "claim packages require exposure evidence without mixed-currency ambiguity"
115                    .to_string(),
116            );
117        }
118        let subject_key = &self
119            .bound_coverage
120            .body
121            .placement
122            .body
123            .quote_response
124            .body
125            .quote_request
126            .body
127            .risk_package
128            .body
129            .subject_key;
130        if self
131            .exposure
132            .body
133            .filters
134            .agent_subject
135            .as_ref()
136            .is_some_and(|agent_subject| agent_subject != subject_key)
137        {
138            return Err(
139                "claim exposure evidence must match the bound coverage subject".to_string(),
140            );
141        }
142        if self
143            .bond
144            .body
145            .report
146            .filters
147            .agent_subject
148            .as_ref()
149            .is_some_and(|agent_subject| agent_subject != subject_key)
150        {
151            return Err("claim bond evidence must match the bound coverage subject".to_string());
152        }
153        if self.loss_event.body.bond_id != self.bond.body.bond_id {
154            return Err("claim loss evidence must reference the same bond".to_string());
155        }
156        if self
157            .loss_event
158            .body
159            .report
160            .summary
161            .agent_subject
162            .as_ref()
163            .is_some_and(|agent_subject| agent_subject != subject_key)
164        {
165            return Err("claim loss evidence must match the bound coverage subject".to_string());
166        }
167        Ok(())
168    }
169}
170
171pub type SignedLiabilityClaimPackage = SignedExportEnvelope<LiabilityClaimPackageArtifact>;
172
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
174#[serde(rename_all = "camelCase")]
175pub struct LiabilityClaimResponseArtifact {
176    pub schema: String,
177    pub claim_response_id: String,
178    pub issued_at: u64,
179    pub claim: SignedLiabilityClaimPackage,
180    pub provider_response_ref: String,
181    pub disposition: LiabilityClaimResponseDisposition,
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub covered_amount: Option<MonetaryAmount>,
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub response_note: Option<String>,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub denial_reason: Option<String>,
188    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189    pub evidence_refs: Vec<LiabilityClaimEvidenceReference>,
190}
191
192impl LiabilityClaimResponseArtifact {
193    pub fn validate(&self) -> Result<(), String> {
194        verify_signed_artifact(&self.claim, "claim response claim")?;
195        self.claim.body.validate()?;
196        if self.provider_response_ref.trim().is_empty() {
197            return Err("claim responses require a non-empty provider_response_ref".to_string());
198        }
199        match self.disposition {
200            LiabilityClaimResponseDisposition::Acknowledged => {
201                if self.covered_amount.is_some() {
202                    return Err(
203                        "acknowledged claim responses cannot include covered_amount".to_string()
204                    );
205                }
206                if self.denial_reason.is_some() {
207                    return Err(
208                        "acknowledged claim responses cannot include denial_reason".to_string()
209                    );
210                }
211            }
212            LiabilityClaimResponseDisposition::Accepted => {
213                let covered_amount = self
214                    .covered_amount
215                    .as_ref()
216                    .ok_or_else(|| "accepted claim responses require covered_amount".to_string())?;
217                validate_positive_money(covered_amount, "covered_amount")?;
218                if covered_amount.currency != self.claim.body.claim_amount.currency {
219                    return Err(
220                        "covered_amount currency must match claim_amount currency".to_string()
221                    );
222                }
223                if covered_amount.units > self.claim.body.claim_amount.units {
224                    return Err("covered_amount cannot exceed claim_amount".to_string());
225                }
226                if self.denial_reason.is_some() {
227                    return Err("accepted claim responses cannot include denial_reason".to_string());
228                }
229            }
230            LiabilityClaimResponseDisposition::Denied => {
231                if self.covered_amount.is_some() {
232                    return Err("denied claim responses cannot include covered_amount".to_string());
233                }
234                if self
235                    .denial_reason
236                    .as_ref()
237                    .is_none_or(|reason| reason.trim().is_empty())
238                {
239                    return Err("denied claim responses require denial_reason".to_string());
240                }
241            }
242        }
243        Ok(())
244    }
245}
246
247pub type SignedLiabilityClaimResponse = SignedExportEnvelope<LiabilityClaimResponseArtifact>;
248
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
250#[serde(rename_all = "camelCase")]
251pub struct LiabilityClaimDisputeArtifact {
252    pub schema: String,
253    pub dispute_id: String,
254    pub issued_at: u64,
255    pub provider_response: SignedLiabilityClaimResponse,
256    pub opened_by: String,
257    pub reason: String,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub note: Option<String>,
260    #[serde(default, skip_serializing_if = "Vec::is_empty")]
261    pub evidence_refs: Vec<LiabilityClaimEvidenceReference>,
262}
263
264impl LiabilityClaimDisputeArtifact {
265    pub fn validate(&self) -> Result<(), String> {
266        verify_signed_artifact(&self.provider_response, "claim dispute provider_response")?;
267        self.provider_response.body.validate()?;
268        if self.opened_by.trim().is_empty() {
269            return Err("claim disputes require a non-empty opened_by".to_string());
270        }
271        if self.reason.trim().is_empty() {
272            return Err("claim disputes require a non-empty reason".to_string());
273        }
274        let partially_accepted = self.provider_response.body.disposition
275            == LiabilityClaimResponseDisposition::Accepted
276            && self
277                .provider_response
278                .body
279                .covered_amount
280                .as_ref()
281                .is_some_and(|amount| {
282                    amount.units < self.provider_response.body.claim.body.claim_amount.units
283                });
284        if self.provider_response.body.disposition != LiabilityClaimResponseDisposition::Denied
285            && !partially_accepted
286        {
287            return Err(
288                "claim disputes require a denied or partially accepted provider response"
289                    .to_string(),
290            );
291        }
292        Ok(())
293    }
294}
295
296pub type SignedLiabilityClaimDispute = SignedExportEnvelope<LiabilityClaimDisputeArtifact>;
297
298#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
299#[serde(rename_all = "camelCase")]
300pub struct LiabilityClaimAdjudicationArtifact {
301    pub schema: String,
302    pub adjudication_id: String,
303    pub issued_at: u64,
304    pub dispute: SignedLiabilityClaimDispute,
305    pub adjudicator: String,
306    pub outcome: LiabilityClaimAdjudicationOutcome,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub awarded_amount: Option<MonetaryAmount>,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub note: Option<String>,
311    /// Predeclared decision rule or circuit-breaker condition id the
312    /// adjudication applied (ADR-0015 follow-up B). Optional and omitted when
313    /// absent so existing signed fixtures keep byte-stable canonical JSON.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub decision_rule_ref: Option<String>,
316    /// Id or hash of the signed roster artifact the adjudicator was checked
317    /// against (ADR-0015 follow-up B anchoring). Records which ex-ante roster
318    /// was applied so the check is auditable and not per-adjudication fabricable.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub roster_anchor_ref: Option<String>,
321    #[serde(default, skip_serializing_if = "Vec::is_empty")]
322    pub evidence_refs: Vec<LiabilityClaimEvidenceReference>,
323}
324
325impl LiabilityClaimAdjudicationArtifact {
326    pub fn validate(&self) -> Result<(), String> {
327        verify_signed_artifact(&self.dispute, "claim adjudication dispute")?;
328        self.dispute.body.validate()?;
329        if self.adjudicator.trim().is_empty() {
330            return Err("claim adjudications require a non-empty adjudicator".to_string());
331        }
332        if self
333            .decision_rule_ref
334            .as_ref()
335            .is_some_and(|rule| rule.trim().is_empty())
336        {
337            return Err(
338                "claim adjudication decision_rule_ref must not be blank when present".to_string(),
339            );
340        }
341        if self
342            .roster_anchor_ref
343            .as_ref()
344            .is_some_and(|anchor| anchor.trim().is_empty())
345        {
346            return Err(
347                "claim adjudication roster_anchor_ref must not be blank when present".to_string(),
348            );
349        }
350        let claim_amount = &self
351            .dispute
352            .body
353            .provider_response
354            .body
355            .claim
356            .body
357            .claim_amount;
358        match self.outcome {
359            LiabilityClaimAdjudicationOutcome::ClaimUpheld => {
360                let awarded_amount = self.awarded_amount.as_ref().ok_or_else(|| {
361                    "claim_upheld adjudications require awarded_amount".to_string()
362                })?;
363                validate_positive_money(awarded_amount, "awarded_amount")?;
364                if awarded_amount.currency != claim_amount.currency {
365                    return Err(
366                        "awarded_amount currency must match claim_amount currency".to_string()
367                    );
368                }
369                if awarded_amount.units > claim_amount.units {
370                    return Err("awarded_amount cannot exceed claim_amount".to_string());
371                }
372            }
373            LiabilityClaimAdjudicationOutcome::ProviderUpheld => {
374                if self.awarded_amount.is_some() {
375                    return Err(
376                        "provider_upheld adjudications cannot include awarded_amount".to_string(),
377                    );
378                }
379            }
380            LiabilityClaimAdjudicationOutcome::PartialSettlement => {
381                let awarded_amount = self.awarded_amount.as_ref().ok_or_else(|| {
382                    "partial_settlement adjudications require awarded_amount".to_string()
383                })?;
384                validate_positive_money(awarded_amount, "awarded_amount")?;
385                if awarded_amount.currency != claim_amount.currency {
386                    return Err(
387                        "awarded_amount currency must match claim_amount currency".to_string()
388                    );
389                }
390                if awarded_amount.units >= claim_amount.units {
391                    return Err(
392                        "partial_settlement awarded_amount must be less than claim_amount"
393                            .to_string(),
394                    );
395                }
396            }
397        }
398        Ok(())
399    }
400
401    /// Fail-closed policy gate for ADR-0015 follow-up B.
402    ///
403    /// Requires the adjudicator to be an exact (trimmed) member of the
404    /// operator-supplied predeclared `roster`, requires `decision_rule_ref` to
405    /// be present and a member of `allowed_decision_rules`, and requires the
406    /// recorded `roster_anchor_ref` to equal `roster_anchor` (the id/hash of the
407    /// signed roster artifact the `roster` was drawn from). Callers pass concrete
408    /// values so `chio-market` needs no dependency on the roster's source crate.
409    pub fn validate_against_roster(
410        &self,
411        roster: &[String],
412        allowed_decision_rules: &[String],
413        roster_anchor: &str,
414    ) -> Result<(), String> {
415        let adjudicator = self.adjudicator.trim();
416        if !roster.iter().any(|entry| entry.trim() == adjudicator) {
417            return Err(format!(
418                "adjudicator \"{adjudicator}\" is not on the predeclared roster"
419            ));
420        }
421        let rule = self
422            .decision_rule_ref
423            .as_ref()
424            .map(|rule| rule.trim())
425            .filter(|rule| !rule.is_empty())
426            .ok_or_else(|| {
427                "adjudication is missing a decision_rule_ref (ADR-0015 follow-up B)".to_string()
428            })?;
429        if !allowed_decision_rules
430            .iter()
431            .any(|allowed| allowed.trim() == rule)
432        {
433            return Err(format!(
434                "decision_rule_ref \"{rule}\" is not an allowed decision rule"
435            ));
436        }
437        let recorded_anchor = self
438            .roster_anchor_ref
439            .as_ref()
440            .map(|anchor| anchor.trim())
441            .filter(|anchor| !anchor.is_empty())
442            .ok_or_else(|| {
443                "adjudication is missing a roster_anchor_ref (ADR-0015 follow-up B)".to_string()
444            })?;
445        if recorded_anchor != roster_anchor.trim() {
446            return Err(format!(
447                "roster_anchor_ref \"{recorded_anchor}\" does not match the applied roster anchor \"{}\"",
448                roster_anchor.trim()
449            ));
450        }
451        Ok(())
452    }
453}
454
455pub type SignedLiabilityClaimAdjudication =
456    SignedExportEnvelope<LiabilityClaimAdjudicationArtifact>;