use serde::{Deserialize, Serialize};
use chio_core_types::canonical::canonical_json_bytes;
use chio_core_types::capability::MonetaryAmount;
use chio_core_types::crypto::{sha256_hex, PublicKey, Signature};
use chio_underwriting::{price_premium, LookbackWindow, PremiumInputs, PremiumQuote};
pub const INSURANCE_CLAIM_LANE_KIND: &str = "chio.insurance.claim.v1";
#[derive(Debug, thiserror::Error)]
pub enum InsuranceFlowError {
#[error("premium declined: {0}")]
PremiumDeclined(String),
#[error("policy unavailable: {0}")]
PolicyUnavailable(String),
#[error("claim evidence invalid: {0}")]
InvalidEvidence(String),
#[error("settlement submission failed: {0}")]
SettlementFailed(String),
#[error("invalid input: {0}")]
InvalidInput(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ReceiptFingerprint {
pub receipt_id: String,
pub body_sha256: String,
}
pub trait PremiumSource {
fn premium_inputs(
&self,
agent_id: &str,
scope: &str,
lookback_window: LookbackWindow,
) -> Result<PremiumInputs, String>;
}
#[derive(Debug, Clone)]
pub struct StaticPremiumSource {
inputs: PremiumInputs,
}
impl StaticPremiumSource {
#[must_use]
pub fn new(inputs: PremiumInputs) -> Self {
Self { inputs }
}
}
impl PremiumSource for StaticPremiumSource {
fn premium_inputs(
&self,
_agent_id: &str,
_scope: &str,
_lookback_window: LookbackWindow,
) -> Result<PremiumInputs, String> {
Ok(self.inputs.clone())
}
}
pub trait ReceiptEvidenceSource {
fn resolve(&self, receipt_id: &str) -> Result<ResolvedReceiptEvidence, String>;
}
#[derive(Debug, Clone)]
pub struct ResolvedReceiptEvidence {
pub body_sha256: String,
pub signer_key: PublicKey,
pub signature: Signature,
pub canonical_body: Vec<u8>,
}
pub trait ClaimSettlementSink {
fn submit(&self, request: ClaimSettlementRequest) -> Result<String, String>;
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CoverageLimit {
pub amount_cents: u64,
pub currency: String,
}
impl CoverageLimit {
#[must_use]
pub fn default_from_premium(quoted_cents: u64, currency: &str) -> Self {
Self {
amount_cents: quoted_cents.saturating_mul(100),
currency: currency.to_ascii_uppercase(),
}
}
#[must_use]
pub fn to_monetary(&self) -> MonetaryAmount {
MonetaryAmount {
units: self.amount_cents,
currency: self.currency.clone(),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PolicyStatus {
Active,
Expired,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BoundPolicy {
pub policy_id: String,
pub agent_id: String,
pub scope: String,
pub premium_quote: PremiumQuote,
pub coverage_limit: CoverageLimit,
pub effective_at: u64,
pub expires_at: u64,
pub status: PolicyStatus,
}
impl BoundPolicy {
fn new(
agent_id: &str,
scope: &str,
premium_quote: PremiumQuote,
coverage_limit: CoverageLimit,
effective_at: u64,
expires_at: u64,
) -> Result<Self, InsuranceFlowError> {
if expires_at <= effective_at {
return Err(InsuranceFlowError::InvalidInput(format!(
"policy expires_at ({expires_at}) must be greater than effective_at ({effective_at})"
)));
}
let policy_id =
compute_policy_id(agent_id, scope, &premium_quote, effective_at, expires_at)?;
Ok(Self {
policy_id,
agent_id: agent_id.to_string(),
scope: scope.to_string(),
premium_quote,
coverage_limit,
effective_at,
expires_at,
status: PolicyStatus::Active,
})
}
pub fn new_with_coverage(
agent_id: &str,
scope: &str,
premium_quote: PremiumQuote,
coverage_limit: CoverageLimit,
effective_at: u64,
expires_at: u64,
) -> Result<Self, InsuranceFlowError> {
Self::new(
agent_id,
scope,
premium_quote,
coverage_limit,
effective_at,
expires_at,
)
}
#[must_use]
pub fn is_in_force(&self, now: u64) -> bool {
matches!(self.status, PolicyStatus::Active)
&& self.effective_at <= now
&& now < self.expires_at
}
pub fn file_claim(
&self,
evidence: &ClaimEvidence,
now: u64,
receipts: &dyn ReceiptEvidenceSource,
settlement_sink: &dyn ClaimSettlementSink,
) -> Result<ClaimDecision, InsuranceFlowError> {
if evidence.policy_id != self.policy_id {
return Err(InsuranceFlowError::PolicyUnavailable(format!(
"claim evidence policy_id `{}` does not match policy `{}`",
evidence.policy_id, self.policy_id
)));
}
if !self.is_in_force(now) {
return Ok(ClaimDecision::Denied {
policy_id: self.policy_id.clone(),
claim_id: evidence.claim_id.clone(),
reason: ClaimDenialReason::PolicyNotInForce,
justification: format!(
"policy status {:?} at {now}, effective_at={}, expires_at={}",
self.status, self.effective_at, self.expires_at
),
});
}
if evidence.requested_amount.currency != self.coverage_limit.currency {
return Ok(ClaimDecision::Denied {
policy_id: self.policy_id.clone(),
claim_id: evidence.claim_id.clone(),
reason: ClaimDenialReason::CurrencyMismatch,
justification: format!(
"claim currency `{}` does not match policy currency `{}`",
evidence.requested_amount.currency, self.coverage_limit.currency
),
});
}
if evidence.supporting_receipts.is_empty() {
return Ok(ClaimDecision::Denied {
policy_id: self.policy_id.clone(),
claim_id: evidence.claim_id.clone(),
reason: ClaimDenialReason::InsufficientEvidence,
justification: "claim evidence did not reference any supporting receipts"
.to_string(),
});
}
for fingerprint in &evidence.supporting_receipts {
let resolved = match receipts.resolve(&fingerprint.receipt_id) {
Ok(resolved) => resolved,
Err(error) => {
return Ok(ClaimDecision::Denied {
policy_id: self.policy_id.clone(),
claim_id: evidence.claim_id.clone(),
reason: ClaimDenialReason::EvidenceUnresolvable,
justification: format!(
"receipt `{}` could not be resolved: {error}",
fingerprint.receipt_id
),
});
}
};
if resolved.body_sha256 != fingerprint.body_sha256 {
return Ok(ClaimDecision::Denied {
policy_id: self.policy_id.clone(),
claim_id: evidence.claim_id.clone(),
reason: ClaimDenialReason::EvidenceDigestMismatch,
justification: format!(
"receipt `{}` body digest does not match evidence fingerprint",
fingerprint.receipt_id
),
});
}
if !resolved
.signer_key
.verify(&resolved.canonical_body, &resolved.signature)
{
return Ok(ClaimDecision::Denied {
policy_id: self.policy_id.clone(),
claim_id: evidence.claim_id.clone(),
reason: ClaimDenialReason::SignatureInvalid,
justification: format!(
"kernel signature on receipt `{}` failed verification",
fingerprint.receipt_id
),
});
}
}
let payable_cents = evidence
.requested_amount
.units
.min(self.coverage_limit.amount_cents);
let payable_amount = MonetaryAmount {
units: payable_cents,
currency: self.coverage_limit.currency.clone(),
};
let receipt_reference = evidence
.supporting_receipts
.first()
.map(|fingerprint| fingerprint.receipt_id.clone())
.unwrap_or_default();
let request = ClaimSettlementRequest {
chain_id: evidence.settlement_chain_id.clone(),
lane_kind: INSURANCE_CLAIM_LANE_KIND.to_string(),
capability_commitment: self.policy_id.clone(),
receipt_reference,
operator_identity: format!("chio-market:insurance-flow:{}", self.agent_id),
settlement_amount: payable_amount.clone(),
};
let settlement_reference = settlement_sink
.submit(request.clone())
.map_err(InsuranceFlowError::SettlementFailed)?;
Ok(ClaimDecision::Approved {
policy_id: self.policy_id.clone(),
claim_id: evidence.claim_id.clone(),
payable_amount,
settlement: Box::new(ClaimSettlement {
request,
settlement_reference,
}),
justification: format!(
"{} supporting receipt(s) verified against kernel signing key; \
payout capped at coverage limit",
evidence.supporting_receipts.len()
),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ClaimEvidence {
pub claim_id: String,
pub policy_id: String,
pub requested_amount: MonetaryAmount,
pub incident_description: String,
pub supporting_receipts: Vec<ReceiptFingerprint>,
pub settlement_chain_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "disposition", rename_all = "snake_case")]
pub enum ClaimDecision {
Approved {
policy_id: String,
claim_id: String,
payable_amount: MonetaryAmount,
settlement: Box<ClaimSettlement>,
justification: String,
},
Denied {
policy_id: String,
claim_id: String,
reason: ClaimDenialReason,
justification: String,
},
}
impl ClaimDecision {
#[must_use]
pub fn is_approved(&self) -> bool {
matches!(self, ClaimDecision::Approved { .. })
}
#[must_use]
pub fn settlement(&self) -> Option<&ClaimSettlement> {
match self {
ClaimDecision::Approved { settlement, .. } => Some(settlement.as_ref()),
ClaimDecision::Denied { .. } => None,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ClaimDenialReason {
PolicyNotInForce,
CurrencyMismatch,
InsufficientEvidence,
EvidenceUnresolvable,
EvidenceDigestMismatch,
SignatureInvalid,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ClaimSettlementRequest {
pub chain_id: String,
pub lane_kind: String,
pub capability_commitment: String,
pub receipt_reference: String,
pub operator_identity: String,
pub settlement_amount: MonetaryAmount,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ClaimSettlement {
pub request: ClaimSettlementRequest,
pub settlement_reference: String,
}
pub fn quote_and_bind(
agent_id: &str,
scope: &str,
lookback_window: LookbackWindow,
premium_source: &dyn PremiumSource,
effective_at: u64,
policy_duration_secs: u64,
) -> Result<BoundPolicy, InsuranceFlowError> {
if agent_id.trim().is_empty() {
return Err(InsuranceFlowError::InvalidInput(
"agent_id must not be empty".to_string(),
));
}
if scope.trim().is_empty() {
return Err(InsuranceFlowError::InvalidInput(
"scope must not be empty".to_string(),
));
}
if policy_duration_secs == 0 {
return Err(InsuranceFlowError::InvalidInput(
"policy_duration_secs must be greater than zero".to_string(),
));
}
let inputs = premium_source
.premium_inputs(agent_id, scope, lookback_window)
.map_err(|error| {
InsuranceFlowError::PremiumDeclined(format!(
"premium source failed for agent `{agent_id}` scope `{scope}`: {error}"
))
})?;
let quote = price_premium(agent_id, scope, lookback_window, &inputs);
let (quoted_cents, currency) = match "e {
PremiumQuote::Quoted {
quoted_cents,
currency,
..
} => (*quoted_cents, currency.clone()),
PremiumQuote::Declined {
reason,
justification,
..
} => {
return Err(InsuranceFlowError::PremiumDeclined(format!(
"{reason:?}: {justification}"
)));
}
};
let coverage_limit = CoverageLimit::default_from_premium(quoted_cents, ¤cy);
let expires_at = effective_at
.checked_add(policy_duration_secs)
.ok_or_else(|| {
InsuranceFlowError::InvalidInput(format!(
"effective_at ({effective_at}) + policy_duration_secs ({policy_duration_secs}) overflows u64"
))
})?;
BoundPolicy::new(
agent_id,
scope,
quote,
coverage_limit,
effective_at,
expires_at,
)
}
fn compute_policy_id(
agent_id: &str,
scope: &str,
quote: &PremiumQuote,
effective_at: u64,
expires_at: u64,
) -> Result<String, InsuranceFlowError> {
let canonical = canonical_json_bytes(&(
"chio.market.insurance-policy.v1",
agent_id,
scope,
quote,
effective_at,
expires_at,
))
.map_err(|error| {
InsuranceFlowError::InvalidInput(format!("failed to canonicalize policy identity: {error}"))
})?;
Ok(format!("insp-{}", sha256_hex(&canonical)))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use chio_core_types::canonical::canonical_json_bytes;
use chio_core_types::crypto::Keypair;
fn window() -> LookbackWindow {
LookbackWindow::new(1_000_000, 1_000_600).unwrap()
}
fn static_source(score: u32) -> StaticPremiumSource {
StaticPremiumSource::new(PremiumInputs::new(Some(score), None, 1_000, "USD"))
}
struct RejectingSource;
impl PremiumSource for RejectingSource {
fn premium_inputs(
&self,
_agent_id: &str,
_scope: &str,
_lookback_window: LookbackWindow,
) -> Result<PremiumInputs, String> {
Err("kernel unavailable".to_string())
}
}
struct InMemoryReceiptSource {
entries: std::collections::BTreeMap<String, ResolvedReceiptEvidence>,
}
impl ReceiptEvidenceSource for InMemoryReceiptSource {
fn resolve(&self, receipt_id: &str) -> Result<ResolvedReceiptEvidence, String> {
self.entries
.get(receipt_id)
.cloned()
.ok_or_else(|| format!("receipt `{receipt_id}` not found"))
}
}
struct CapturingSink {
events: std::sync::Mutex<Vec<ClaimSettlementRequest>>,
}
impl CapturingSink {
fn new() -> Self {
Self {
events: std::sync::Mutex::new(Vec::new()),
}
}
fn events(&self) -> Vec<ClaimSettlementRequest> {
self.events.lock().unwrap().clone()
}
}
impl ClaimSettlementSink for CapturingSink {
fn submit(&self, request: ClaimSettlementRequest) -> Result<String, String> {
let mut events = self.events.lock().map_err(|error| error.to_string())?;
let reference = format!("settle-ref-{}", events.len());
events.push(request);
Ok(reference)
}
}
fn fake_receipt(
keypair: &Keypair,
receipt_id: &str,
) -> (ReceiptFingerprint, ResolvedReceiptEvidence) {
let body = ("chio.receipt.fixture.v1", receipt_id, 1_000_000u64);
let canonical = canonical_json_bytes(&body).unwrap();
let body_sha256 = sha256_hex(&canonical);
let signature = keypair.sign(&canonical);
let signer_key = keypair.public_key();
(
ReceiptFingerprint {
receipt_id: receipt_id.to_string(),
body_sha256: body_sha256.clone(),
},
ResolvedReceiptEvidence {
body_sha256,
signer_key,
signature,
canonical_body: canonical,
},
)
}
#[test]
fn clean_agent_binds_policy_with_quoted_premium() {
let policy = quote_and_bind(
"agent-clean",
"tool:exec",
window(),
&static_source(950),
1_000_600,
60 * 60 * 24 * 30,
)
.unwrap();
assert_eq!(policy.agent_id, "agent-clean");
assert!(policy.premium_quote.is_quoted());
assert_eq!(policy.status, PolicyStatus::Active);
assert!(policy.policy_id.starts_with("insp-"));
assert_eq!(policy.coverage_limit.amount_cents, 2_000 * 100);
}
#[test]
fn denial_below_floor_returns_premium_declined() {
let error = quote_and_bind(
"agent-bad",
"tool:exec",
window(),
&static_source(200),
1_000_600,
60 * 60 * 24 * 30,
)
.unwrap_err();
assert!(matches!(error, InsuranceFlowError::PremiumDeclined(_)));
}
#[test]
fn rejecting_source_surfaces_fail_closed_decline() {
let error = quote_and_bind(
"agent",
"tool:exec",
window(),
&RejectingSource,
1_000_600,
60 * 60 * 24 * 30,
)
.unwrap_err();
match error {
InsuranceFlowError::PremiumDeclined(message) => {
assert!(message.contains("kernel unavailable"));
}
other => panic!("expected PremiumDeclined, got {other:?}"),
}
}
#[test]
fn file_claim_with_verified_receipt_is_approved_and_submits_settlement() {
let keypair = Keypair::generate();
let policy = quote_and_bind(
"agent-clean",
"tool:exec",
window(),
&static_source(950),
1_000_600,
60 * 60 * 24 * 30,
)
.unwrap();
let (fingerprint, resolved) = fake_receipt(&keypair, "rcpt-1");
let receipts = InMemoryReceiptSource {
entries: std::collections::BTreeMap::from([(fingerprint.receipt_id.clone(), resolved)]),
};
let sink = CapturingSink::new();
let evidence = ClaimEvidence {
claim_id: "claim-1".to_string(),
policy_id: policy.policy_id.clone(),
requested_amount: MonetaryAmount {
units: 100_000,
currency: "USD".to_string(),
},
incident_description: "tool execution caused downstream loss".to_string(),
supporting_receipts: vec![fingerprint],
settlement_chain_id: "ethereum-mainnet".to_string(),
};
let decision = policy
.file_claim(&evidence, 1_001_000, &receipts, &sink)
.unwrap();
assert!(decision.is_approved(), "decision={decision:?}");
let settlement = decision.settlement().unwrap();
assert_eq!(settlement.request.lane_kind, INSURANCE_CLAIM_LANE_KIND);
assert_eq!(settlement.request.capability_commitment, policy.policy_id);
let events = sink.events();
assert_eq!(
events.len(),
1,
"one settlement request should be submitted"
);
assert_eq!(events[0].settlement_amount.currency, "USD");
}
#[test]
fn file_claim_denies_when_receipt_cannot_be_resolved() {
let keypair = Keypair::generate();
let policy = quote_and_bind(
"agent-clean",
"tool:exec",
window(),
&static_source(950),
1_000_600,
60 * 60 * 24 * 30,
)
.unwrap();
let (fingerprint, _resolved) = fake_receipt(&keypair, "rcpt-missing");
let receipts = InMemoryReceiptSource {
entries: std::collections::BTreeMap::new(),
};
let sink = CapturingSink::new();
let evidence = ClaimEvidence {
claim_id: "claim-miss".to_string(),
policy_id: policy.policy_id.clone(),
requested_amount: MonetaryAmount {
units: 100_000,
currency: "USD".to_string(),
},
incident_description: "missing receipt".to_string(),
supporting_receipts: vec![fingerprint],
settlement_chain_id: "ethereum-mainnet".to_string(),
};
let decision = policy
.file_claim(&evidence, 1_001_000, &receipts, &sink)
.unwrap();
match decision {
ClaimDecision::Denied { reason, .. } => {
assert_eq!(reason, ClaimDenialReason::EvidenceUnresolvable);
}
ClaimDecision::Approved { .. } => panic!("expected denial for missing evidence"),
}
assert!(sink.events().is_empty());
}
#[test]
fn file_claim_denies_when_receipt_signature_is_tampered() {
let real = Keypair::generate();
let imposter = Keypair::generate();
let policy = quote_and_bind(
"agent-clean",
"tool:exec",
window(),
&static_source(950),
1_000_600,
60 * 60 * 24 * 30,
)
.unwrap();
let (fingerprint, mut resolved) = fake_receipt(&real, "rcpt-tampered");
resolved.signature = imposter.sign(&resolved.canonical_body);
let receipts = InMemoryReceiptSource {
entries: std::collections::BTreeMap::from([(fingerprint.receipt_id.clone(), resolved)]),
};
let sink = CapturingSink::new();
let evidence = ClaimEvidence {
claim_id: "claim-tamper".to_string(),
policy_id: policy.policy_id.clone(),
requested_amount: MonetaryAmount {
units: 100_000,
currency: "USD".to_string(),
},
incident_description: "tampered receipt".to_string(),
supporting_receipts: vec![fingerprint],
settlement_chain_id: "ethereum-mainnet".to_string(),
};
let decision = policy
.file_claim(&evidence, 1_001_000, &receipts, &sink)
.unwrap();
match decision {
ClaimDecision::Denied { reason, .. } => {
assert_eq!(reason, ClaimDenialReason::SignatureInvalid);
}
ClaimDecision::Approved { .. } => panic!("expected denial for tampered evidence"),
}
assert!(sink.events().is_empty());
}
#[test]
fn file_claim_caps_payout_at_coverage_limit() {
let keypair = Keypair::generate();
let policy = quote_and_bind(
"agent-clean",
"tool:exec",
window(),
&static_source(950),
1_000_600,
60 * 60 * 24 * 30,
)
.unwrap();
let (fingerprint, resolved) = fake_receipt(&keypair, "rcpt-huge");
let receipts = InMemoryReceiptSource {
entries: std::collections::BTreeMap::from([(fingerprint.receipt_id.clone(), resolved)]),
};
let sink = CapturingSink::new();
let huge_request = policy.coverage_limit.amount_cents.saturating_mul(10);
let evidence = ClaimEvidence {
claim_id: "claim-big".to_string(),
policy_id: policy.policy_id.clone(),
requested_amount: MonetaryAmount {
units: huge_request,
currency: "USD".to_string(),
},
incident_description: "oversized loss".to_string(),
supporting_receipts: vec![fingerprint],
settlement_chain_id: "ethereum-mainnet".to_string(),
};
let decision = policy
.file_claim(&evidence, 1_001_000, &receipts, &sink)
.unwrap();
match decision {
ClaimDecision::Approved { payable_amount, .. } => {
assert_eq!(payable_amount.units, policy.coverage_limit.amount_cents);
}
ClaimDecision::Denied { reason, .. } => {
panic!("expected approval with capped payout, got {reason:?}")
}
}
}
#[test]
fn file_claim_denies_after_policy_expires() {
let keypair = Keypair::generate();
let policy = quote_and_bind(
"agent-clean",
"tool:exec",
window(),
&static_source(950),
1_000_600,
60,
)
.unwrap();
let (fingerprint, resolved) = fake_receipt(&keypair, "rcpt-late");
let receipts = InMemoryReceiptSource {
entries: std::collections::BTreeMap::from([(fingerprint.receipt_id.clone(), resolved)]),
};
let sink = CapturingSink::new();
let evidence = ClaimEvidence {
claim_id: "claim-late".to_string(),
policy_id: policy.policy_id.clone(),
requested_amount: MonetaryAmount {
units: 100,
currency: "USD".to_string(),
},
incident_description: "too late".to_string(),
supporting_receipts: vec![fingerprint],
settlement_chain_id: "ethereum-mainnet".to_string(),
};
let decision = policy
.file_claim(&evidence, 1_000_600 + 3_600, &receipts, &sink)
.unwrap();
match decision {
ClaimDecision::Denied { reason, .. } => {
assert_eq!(reason, ClaimDenialReason::PolicyNotInForce);
}
ClaimDecision::Approved { .. } => panic!("expected denial after expiry"),
}
assert!(sink.events().is_empty());
}
}