Skip to main content

chio_underwriting/
lib.rs

1pub use chio_appraisal as appraisal;
2pub use chio_core_types::{canonical, capability, crypto, receipt};
3
4pub mod premium;
5pub use premium::{
6    price_premium, risk_multiplier, LookbackWindow, PremiumDeclineReason, PremiumInputs,
7    PremiumQuote, DEFAULT_BEHAVIORAL_PENALTY_CAP, DEFAULT_BEHAVIORAL_PENALTY_PER_SIGMA,
8    PREMIUM_DECLINE_FLOOR, PREMIUM_HIGH_RISK_FLOOR, PREMIUM_LOW_RISK_FLOOR,
9    PREMIUM_MEDIUM_RISK_FLOOR,
10};
11
12use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15
16use crate::appraisal::AttestationVerifierFamily;
17use crate::canonical::canonical_json_bytes;
18use crate::capability::{MonetaryAmount, RuntimeAssuranceTier};
19use crate::crypto::sha256_hex;
20use crate::receipt::SignedExportEnvelope;
21
22pub const UNDERWRITING_POLICY_INPUT_SCHEMA: &str = "chio.underwriting.policy-input.v1";
23pub const UNDERWRITING_COMPLIANCE_EVIDENCE_SCHEMA: &str =
24    "chio.underwriting.compliance-evidence.v1";
25pub const UNDERWRITING_RISK_TAXONOMY_VERSION: &str = "chio.underwriting.taxonomy.v1";
26pub const UNDERWRITING_DECISION_POLICY_SCHEMA: &str = "chio.underwriting.decision-policy.v1";
27pub const UNDERWRITING_DECISION_POLICY_VERSION: &str =
28    "chio.underwriting.decision-policy.default.v1";
29pub const UNDERWRITING_DECISION_REPORT_SCHEMA: &str = "chio.underwriting.decision-report.v1";
30pub const UNDERWRITING_SIMULATION_REPORT_SCHEMA: &str = "chio.underwriting.simulation-report.v1";
31pub const UNDERWRITING_DECISION_ARTIFACT_SCHEMA: &str = "chio.underwriting.decision.v1";
32pub const UNDERWRITING_APPEAL_SCHEMA: &str = "chio.underwriting.appeal.v1";
33pub const MAX_UNDERWRITING_RECEIPT_LIMIT: usize = 200;
34pub const MAX_UNDERWRITING_DECISION_LIMIT: usize = 200;
35
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
37#[serde(rename_all = "snake_case")]
38pub enum UnderwritingRiskClass {
39    Baseline,
40    Guarded,
41    Elevated,
42    Critical,
43}
44
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(rename_all = "snake_case")]
47pub enum UnderwritingReasonCode {
48    ProbationaryHistory,
49    LowReputation,
50    ImportedTrustDependency,
51    MissingCertification,
52    FailedCertification,
53    RevokedCertification,
54    MissingRuntimeAssurance,
55    WeakRuntimeAssurance,
56    PendingSettlementExposure,
57    FailedSettlementExposure,
58    MeteredBillingMismatch,
59    DelegatedCallChain,
60    SharedEvidenceProofRequired,
61}
62
63#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "snake_case")]
65pub enum UnderwritingEvidenceKind {
66    Receipt,
67    ReputationInspection,
68    CertificationArtifact,
69    RuntimeAssuranceEvidence,
70    SettlementReconciliation,
71    MeteredBillingReconciliation,
72    SharedEvidenceReference,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "camelCase")]
77pub struct UnderwritingEvidenceReference {
78    pub kind: UnderwritingEvidenceKind,
79    pub reference_id: String,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub observed_at: Option<u64>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub digest_sha256: Option<String>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub locator: Option<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "camelCase")]
90pub struct UnderwritingSignal {
91    pub class: UnderwritingRiskClass,
92    pub reason: UnderwritingReasonCode,
93    pub description: String,
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub evidence_refs: Vec<UnderwritingEvidenceReference>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99#[serde(rename_all = "snake_case")]
100pub enum UnderwritingCertificationState {
101    Active,
102    Superseded,
103    Revoked,
104    NotFound,
105    Unavailable,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109#[serde(rename_all = "camelCase")]
110pub struct UnderwritingRiskTaxonomy {
111    pub version: String,
112    pub supported_classes: Vec<UnderwritingRiskClass>,
113    pub supported_reasons: Vec<UnderwritingReasonCode>,
114}
115
116impl Default for UnderwritingRiskTaxonomy {
117    fn default() -> Self {
118        Self {
119            version: UNDERWRITING_RISK_TAXONOMY_VERSION.to_string(),
120            supported_classes: vec![
121                UnderwritingRiskClass::Baseline,
122                UnderwritingRiskClass::Guarded,
123                UnderwritingRiskClass::Elevated,
124                UnderwritingRiskClass::Critical,
125            ],
126            supported_reasons: vec![
127                UnderwritingReasonCode::ProbationaryHistory,
128                UnderwritingReasonCode::LowReputation,
129                UnderwritingReasonCode::ImportedTrustDependency,
130                UnderwritingReasonCode::MissingCertification,
131                UnderwritingReasonCode::FailedCertification,
132                UnderwritingReasonCode::RevokedCertification,
133                UnderwritingReasonCode::MissingRuntimeAssurance,
134                UnderwritingReasonCode::WeakRuntimeAssurance,
135                UnderwritingReasonCode::PendingSettlementExposure,
136                UnderwritingReasonCode::FailedSettlementExposure,
137                UnderwritingReasonCode::MeteredBillingMismatch,
138                UnderwritingReasonCode::DelegatedCallChain,
139                UnderwritingReasonCode::SharedEvidenceProofRequired,
140            ],
141        }
142    }
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
146#[serde(rename_all = "camelCase")]
147pub struct UnderwritingReceiptEvidence {
148    pub matching_receipts: u64,
149    pub returned_receipts: u64,
150    pub allow_count: u64,
151    pub deny_count: u64,
152    pub cancelled_count: u64,
153    pub incomplete_count: u64,
154    pub governed_receipts: u64,
155    pub approval_receipts: u64,
156    pub approved_receipts: u64,
157    pub call_chain_receipts: u64,
158    pub runtime_assurance_receipts: u64,
159    pub pending_settlement_receipts: u64,
160    pub failed_settlement_receipts: u64,
161    pub actionable_settlement_receipts: u64,
162    pub metered_receipts: u64,
163    pub actionable_metered_receipts: u64,
164    pub shared_evidence_reference_count: u64,
165    pub shared_evidence_proof_required_count: u64,
166    #[serde(default, skip_serializing_if = "Vec::is_empty")]
167    pub receipt_refs: Vec<UnderwritingEvidenceReference>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
171#[serde(rename_all = "camelCase")]
172pub struct UnderwritingReputationEvidence {
173    pub subject_key: String,
174    pub effective_score: f64,
175    pub probationary: bool,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub resolved_tier: Option<String>,
178    pub imported_signal_count: usize,
179    pub accepted_imported_signal_count: usize,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
183#[serde(rename_all = "camelCase")]
184pub struct UnderwritingCertificationEvidence {
185    pub tool_server_id: String,
186    pub state: UnderwritingCertificationState,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub artifact_id: Option<String>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub verdict: Option<String>,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub checked_at: Option<u64>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub published_at: Option<u64>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
198#[serde(rename_all = "camelCase")]
199pub struct UnderwritingRuntimeAssuranceEvidence {
200    pub governed_receipts: u64,
201    pub runtime_assurance_receipts: u64,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub highest_tier: Option<RuntimeAssuranceTier>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub latest_schema: Option<String>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub latest_verifier_family: Option<AttestationVerifierFamily>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub latest_verifier: Option<String>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub latest_evidence_sha256: Option<String>,
212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
213    pub observed_verifier_families: Vec<AttestationVerifierFamily>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
217#[serde(rename_all = "camelCase")]
218pub struct UnderwritingComplianceEvidence {
219    pub schema: String,
220    pub agent_id: String,
221    pub score: u32,
222    pub generated_at: u64,
223    pub total_receipts: u64,
224    pub deny_receipts: u64,
225    pub observed_capabilities: u64,
226    pub revoked_capabilities: u64,
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub attestation_age_secs: Option<u64>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
232#[serde(rename_all = "camelCase")]
233pub struct UnderwritingPolicyInputQuery {
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub capability_id: Option<String>,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub agent_subject: Option<String>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub tool_server: Option<String>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub tool_name: Option<String>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub since: Option<u64>,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub until: Option<u64>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub receipt_limit: Option<usize>,
248}
249
250impl Default for UnderwritingPolicyInputQuery {
251    fn default() -> Self {
252        Self {
253            capability_id: None,
254            agent_subject: None,
255            tool_server: None,
256            tool_name: None,
257            since: None,
258            until: None,
259            receipt_limit: Some(100),
260        }
261    }
262}
263
264impl UnderwritingPolicyInputQuery {
265    #[must_use]
266    pub fn receipt_limit_or_default(&self) -> usize {
267        self.receipt_limit
268            .unwrap_or(100)
269            .clamp(1, MAX_UNDERWRITING_RECEIPT_LIMIT)
270    }
271
272    #[must_use]
273    pub fn normalized(&self) -> Self {
274        let mut normalized = self.clone();
275        normalized.receipt_limit = Some(self.receipt_limit_or_default());
276        normalized
277    }
278
279    pub fn validate(&self) -> Result<(), String> {
280        if self.capability_id.is_none()
281            && self.agent_subject.is_none()
282            && self.tool_server.is_none()
283        {
284            return Err(
285                "underwriting input queries require at least one anchor: --capability, --agent-subject, or --tool-server".to_string(),
286            );
287        }
288        if self.tool_name.is_some() && self.tool_server.is_none() {
289            return Err(
290                "underwriting input queries that specify --tool-name must also specify --tool-server"
291                    .to_string(),
292            );
293        }
294        if matches!((self.since, self.until), (Some(since), Some(until)) if since > until) {
295            return Err("underwriting input query has since > until".to_string());
296        }
297        Ok(())
298    }
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
302#[serde(rename_all = "camelCase")]
303pub struct UnderwritingPolicyInput {
304    pub schema: String,
305    pub generated_at: u64,
306    pub filters: UnderwritingPolicyInputQuery,
307    pub taxonomy: UnderwritingRiskTaxonomy,
308    pub receipts: UnderwritingReceiptEvidence,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub reputation: Option<UnderwritingReputationEvidence>,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub certification: Option<UnderwritingCertificationEvidence>,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub runtime_assurance: Option<UnderwritingRuntimeAssuranceEvidence>,
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub compliance_score: Option<UnderwritingComplianceEvidence>,
317    #[serde(default, skip_serializing_if = "Vec::is_empty")]
318    pub signals: Vec<UnderwritingSignal>,
319}
320
321pub type SignedUnderwritingPolicyInput = SignedExportEnvelope<UnderwritingPolicyInput>;
322
323#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
324#[serde(rename_all = "snake_case")]
325pub enum UnderwritingDecisionOutcome {
326    Approve,
327    ReduceCeiling,
328    StepUp,
329    Deny,
330}
331
332#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
333#[serde(rename_all = "snake_case")]
334pub enum UnderwritingDecisionReasonCode {
335    PolicySignal,
336    ComplianceScoreRequired,
337    InsufficientReceiptHistory,
338    StaleReceiptHistory,
339    ReputationBelowApproveThreshold,
340    ReputationBelowDenyThreshold,
341    RuntimeAssuranceBelowApproveTier,
342    RuntimeAssuranceBelowStepUpTier,
343}
344
345#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
346#[serde(rename_all = "snake_case")]
347pub enum UnderwritingRemediation {
348    GatherMoreReceiptHistory,
349    RefreshReceiptEvidence,
350    StrongerRuntimeAssurance,
351    ActiveCertification,
352    SettlementResolution,
353    MeteredBillingReconciliation,
354    ManualReview,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
358#[serde(rename_all = "camelCase")]
359pub struct UnderwritingDecisionPolicy {
360    pub schema: String,
361    pub version: String,
362    pub minimum_receipt_history: u64,
363    pub maximum_receipt_age_seconds: u64,
364    pub minimum_approve_reputation_score: f64,
365    pub deny_reputation_score_below: f64,
366    pub minimum_step_up_runtime_assurance_tier: RuntimeAssuranceTier,
367    pub minimum_approve_runtime_assurance_tier: RuntimeAssuranceTier,
368    pub require_active_tool_certification: bool,
369    pub require_compliance_score_reference: bool,
370    pub reduce_ceiling_factor: f64,
371}
372
373impl Default for UnderwritingDecisionPolicy {
374    fn default() -> Self {
375        Self {
376            schema: UNDERWRITING_DECISION_POLICY_SCHEMA.to_string(),
377            version: UNDERWRITING_DECISION_POLICY_VERSION.to_string(),
378            minimum_receipt_history: 1,
379            maximum_receipt_age_seconds: 60 * 60 * 24 * 30,
380            minimum_approve_reputation_score: 0.6,
381            deny_reputation_score_below: 0.25,
382            minimum_step_up_runtime_assurance_tier: RuntimeAssuranceTier::Attested,
383            minimum_approve_runtime_assurance_tier: RuntimeAssuranceTier::Verified,
384            require_active_tool_certification: true,
385            require_compliance_score_reference: false,
386            reduce_ceiling_factor: 0.5,
387        }
388    }
389}
390
391impl UnderwritingDecisionPolicy {
392    pub fn validate(&self) -> Result<(), String> {
393        if self.minimum_receipt_history == 0 {
394            return Err(
395                "underwriting decision policy minimum_receipt_history must be greater than zero"
396                    .to_string(),
397            );
398        }
399        if self.maximum_receipt_age_seconds == 0 {
400            return Err(
401                "underwriting decision policy maximum_receipt_age_seconds must be greater than zero"
402                    .to_string(),
403            );
404        }
405        if !(0.0..=1.0).contains(&self.minimum_approve_reputation_score) {
406            return Err(
407                "underwriting decision policy minimum_approve_reputation_score must be between 0.0 and 1.0"
408                    .to_string(),
409            );
410        }
411        if !(0.0..=1.0).contains(&self.deny_reputation_score_below) {
412            return Err(
413                "underwriting decision policy deny_reputation_score_below must be between 0.0 and 1.0"
414                    .to_string(),
415            );
416        }
417        if self.deny_reputation_score_below >= self.minimum_approve_reputation_score {
418            return Err(
419                "underwriting decision policy deny_reputation_score_below must be less than minimum_approve_reputation_score"
420                    .to_string(),
421            );
422        }
423        if self.minimum_step_up_runtime_assurance_tier > self.minimum_approve_runtime_assurance_tier
424        {
425            return Err(
426                "underwriting decision policy minimum_step_up_runtime_assurance_tier must not exceed minimum_approve_runtime_assurance_tier"
427                    .to_string(),
428            );
429        }
430        if !(0.0..1.0).contains(&self.reduce_ceiling_factor) {
431            return Err(
432                "underwriting decision policy reduce_ceiling_factor must be greater than 0.0 and less than 1.0"
433                    .to_string(),
434            );
435        }
436        Ok(())
437    }
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
441#[serde(rename_all = "camelCase")]
442pub struct UnderwritingDecisionFinding {
443    pub class: UnderwritingRiskClass,
444    pub outcome: UnderwritingDecisionOutcome,
445    pub reason: UnderwritingDecisionReasonCode,
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub signal_reason: Option<UnderwritingReasonCode>,
448    pub description: String,
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub remediation: Option<UnderwritingRemediation>,
451    #[serde(default, skip_serializing_if = "Vec::is_empty")]
452    pub evidence_refs: Vec<UnderwritingEvidenceReference>,
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
456#[serde(rename_all = "camelCase")]
457pub struct UnderwritingDecisionReport {
458    pub schema: String,
459    pub generated_at: u64,
460    pub policy: UnderwritingDecisionPolicy,
461    pub outcome: UnderwritingDecisionOutcome,
462    pub risk_class: UnderwritingRiskClass,
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub suggested_ceiling_factor: Option<f64>,
465    #[serde(default, skip_serializing_if = "Vec::is_empty")]
466    pub findings: Vec<UnderwritingDecisionFinding>,
467    pub input: UnderwritingPolicyInput,
468}
469
470#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
471#[serde(rename_all = "snake_case")]
472pub enum UnderwritingDecisionLifecycleState {
473    Active,
474    Superseded,
475}
476
477#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
478#[serde(rename_all = "snake_case")]
479pub enum UnderwritingReviewState {
480    Approved,
481    ManualReviewRequired,
482    Denied,
483}
484
485#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
486#[serde(rename_all = "snake_case")]
487pub enum UnderwritingBudgetAction {
488    Preserve,
489    Reduce,
490    Hold,
491    Deny,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
495#[serde(rename_all = "camelCase")]
496pub struct UnderwritingBudgetRecommendation {
497    pub action: UnderwritingBudgetAction,
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub ceiling_factor: Option<f64>,
500    pub rationale: String,
501}
502
503#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
504#[serde(rename_all = "snake_case")]
505pub enum UnderwritingPremiumState {
506    Quoted,
507    Withheld,
508    NotApplicable,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
512#[serde(rename_all = "camelCase")]
513pub struct UnderwritingPremiumQuote {
514    pub state: UnderwritingPremiumState,
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub basis_points: Option<u32>,
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    pub quoted_amount: Option<MonetaryAmount>,
519    pub rationale: String,
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
523#[serde(rename_all = "camelCase")]
524pub struct UnderwritingDecisionArtifact {
525    pub schema: String,
526    pub decision_id: String,
527    pub issued_at: u64,
528    pub evaluation: UnderwritingDecisionReport,
529    pub lifecycle_state: UnderwritingDecisionLifecycleState,
530    pub review_state: UnderwritingReviewState,
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    pub supersedes_decision_id: Option<String>,
533    pub budget: UnderwritingBudgetRecommendation,
534    pub premium: UnderwritingPremiumQuote,
535}
536
537pub type SignedUnderwritingDecision = SignedExportEnvelope<UnderwritingDecisionArtifact>;
538
539#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
540#[serde(rename_all = "snake_case")]
541pub enum UnderwritingAppealStatus {
542    Open,
543    Accepted,
544    Rejected,
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
548#[serde(rename_all = "camelCase")]
549pub struct UnderwritingAppealRecord {
550    pub schema: String,
551    pub appeal_id: String,
552    pub decision_id: String,
553    pub requested_by: String,
554    pub reason: String,
555    pub status: UnderwritingAppealStatus,
556    pub created_at: u64,
557    pub updated_at: u64,
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub note: Option<String>,
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub resolved_by: Option<String>,
562    #[serde(default, skip_serializing_if = "Option::is_none")]
563    pub replacement_decision_id: Option<String>,
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
567#[serde(rename_all = "camelCase")]
568pub struct UnderwritingAppealCreateRequest {
569    pub decision_id: String,
570    pub requested_by: String,
571    pub reason: String,
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub note: Option<String>,
574}
575
576#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
577#[serde(rename_all = "snake_case")]
578pub enum UnderwritingAppealResolution {
579    Accepted,
580    Rejected,
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
584#[serde(rename_all = "camelCase")]
585pub struct UnderwritingAppealResolveRequest {
586    pub appeal_id: String,
587    pub resolution: UnderwritingAppealResolution,
588    pub resolved_by: String,
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub note: Option<String>,
591    #[serde(default, skip_serializing_if = "Option::is_none")]
592    pub replacement_decision_id: Option<String>,
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
596#[serde(rename_all = "camelCase")]
597pub struct UnderwritingDecisionQuery {
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    pub decision_id: Option<String>,
600    #[serde(default, skip_serializing_if = "Option::is_none")]
601    pub capability_id: Option<String>,
602    #[serde(default, skip_serializing_if = "Option::is_none")]
603    pub agent_subject: Option<String>,
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub tool_server: Option<String>,
606    #[serde(default, skip_serializing_if = "Option::is_none")]
607    pub tool_name: Option<String>,
608    #[serde(default, skip_serializing_if = "Option::is_none")]
609    pub outcome: Option<UnderwritingDecisionOutcome>,
610    #[serde(default, skip_serializing_if = "Option::is_none")]
611    pub lifecycle_state: Option<UnderwritingDecisionLifecycleState>,
612    #[serde(default, skip_serializing_if = "Option::is_none")]
613    pub appeal_status: Option<UnderwritingAppealStatus>,
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub limit: Option<usize>,
616}
617
618impl Default for UnderwritingDecisionQuery {
619    fn default() -> Self {
620        Self {
621            decision_id: None,
622            capability_id: None,
623            agent_subject: None,
624            tool_server: None,
625            tool_name: None,
626            outcome: None,
627            lifecycle_state: None,
628            appeal_status: None,
629            limit: Some(50),
630        }
631    }
632}
633
634impl UnderwritingDecisionQuery {
635    #[must_use]
636    pub fn limit_or_default(&self) -> usize {
637        self.limit
638            .unwrap_or(50)
639            .clamp(1, MAX_UNDERWRITING_DECISION_LIMIT)
640    }
641
642    #[must_use]
643    pub fn normalized(&self) -> Self {
644        let mut normalized = self.clone();
645        normalized.limit = Some(self.limit_or_default());
646        normalized
647    }
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
651#[serde(rename_all = "camelCase")]
652pub struct UnderwritingDecisionSummary {
653    pub matching_decisions: u64,
654    pub returned_decisions: u64,
655    pub active_decisions: u64,
656    pub superseded_decisions: u64,
657    pub open_appeals: u64,
658    pub accepted_appeals: u64,
659    pub rejected_appeals: u64,
660    pub total_quoted_premium_units: u64,
661    #[serde(default, skip_serializing_if = "Option::is_none")]
662    pub total_quoted_premium_currency: Option<String>,
663    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
664    pub quoted_premium_totals_by_currency: BTreeMap<String, u64>,
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
668#[serde(rename_all = "camelCase")]
669pub struct UnderwritingDecisionRow {
670    pub decision: SignedUnderwritingDecision,
671    pub lifecycle_state: UnderwritingDecisionLifecycleState,
672    pub open_appeal_count: u64,
673    #[serde(default, skip_serializing_if = "Option::is_none")]
674    pub latest_appeal_id: Option<String>,
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub latest_appeal_status: Option<UnderwritingAppealStatus>,
677}
678
679#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
680#[serde(rename_all = "camelCase")]
681pub struct UnderwritingDecisionListReport {
682    pub generated_at: u64,
683    pub filters: UnderwritingDecisionQuery,
684    pub summary: UnderwritingDecisionSummary,
685    pub decisions: Vec<UnderwritingDecisionRow>,
686}
687
688#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
689#[serde(rename_all = "camelCase")]
690pub struct UnderwritingSimulationRequest {
691    pub query: UnderwritingPolicyInputQuery,
692    pub policy: UnderwritingDecisionPolicy,
693}
694
695#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
696#[serde(rename_all = "camelCase")]
697pub struct UnderwritingSimulationDelta {
698    pub outcome_changed: bool,
699    pub risk_class_changed: bool,
700    #[serde(default, skip_serializing_if = "Vec::is_empty")]
701    pub added_reasons: Vec<String>,
702    #[serde(default, skip_serializing_if = "Vec::is_empty")]
703    pub removed_reasons: Vec<String>,
704    #[serde(default, skip_serializing_if = "Option::is_none")]
705    pub default_ceiling_factor: Option<f64>,
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub simulated_ceiling_factor: Option<f64>,
708}
709
710#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
711#[serde(rename_all = "camelCase")]
712pub struct UnderwritingSimulationReport {
713    pub schema: String,
714    pub generated_at: u64,
715    pub input: UnderwritingPolicyInput,
716    pub default_evaluation: UnderwritingDecisionReport,
717    pub simulated_evaluation: UnderwritingDecisionReport,
718    pub delta: UnderwritingSimulationDelta,
719}
720
721pub fn evaluate_underwriting_policy_input(
722    input: UnderwritingPolicyInput,
723    policy: &UnderwritingDecisionPolicy,
724) -> Result<UnderwritingDecisionReport, String> {
725    policy.validate()?;
726
727    let mut findings = Vec::new();
728    let latest_receipt_ref = input
729        .receipts
730        .receipt_refs
731        .iter()
732        .max_by_key(|reference| reference.observed_at.unwrap_or(0))
733        .cloned();
734
735    if input.receipts.matching_receipts < policy.minimum_receipt_history {
736        findings.push(UnderwritingDecisionFinding {
737            class: UnderwritingRiskClass::Elevated,
738            outcome: UnderwritingDecisionOutcome::StepUp,
739            reason: UnderwritingDecisionReasonCode::InsufficientReceiptHistory,
740            signal_reason: None,
741            description: format!(
742                "only {} receipt(s) matched; policy requires at least {}",
743                input.receipts.matching_receipts, policy.minimum_receipt_history
744            ),
745            remediation: Some(UnderwritingRemediation::GatherMoreReceiptHistory),
746            evidence_refs: latest_receipt_ref.clone().into_iter().collect(),
747        });
748    }
749
750    if let Some(latest_receipt_ref) = latest_receipt_ref.as_ref() {
751        if let Some(observed_at) = latest_receipt_ref.observed_at {
752            if input.generated_at.saturating_sub(observed_at) > policy.maximum_receipt_age_seconds {
753                findings.push(UnderwritingDecisionFinding {
754                    class: UnderwritingRiskClass::Elevated,
755                    outcome: UnderwritingDecisionOutcome::StepUp,
756                    reason: UnderwritingDecisionReasonCode::StaleReceiptHistory,
757                    signal_reason: None,
758                    description: format!(
759                        "latest receipt evidence is {}s old, exceeding the {}s freshness window",
760                        input.generated_at.saturating_sub(observed_at),
761                        policy.maximum_receipt_age_seconds
762                    ),
763                    remediation: Some(UnderwritingRemediation::RefreshReceiptEvidence),
764                    evidence_refs: vec![latest_receipt_ref.clone()],
765                });
766            }
767        }
768    }
769
770    if policy.require_compliance_score_reference && input.compliance_score.is_none() {
771        findings.push(UnderwritingDecisionFinding {
772            class: UnderwritingRiskClass::Elevated,
773            outcome: UnderwritingDecisionOutcome::StepUp,
774            reason: UnderwritingDecisionReasonCode::ComplianceScoreRequired,
775            signal_reason: None,
776            description:
777                "policy requires a compliance-score reference, but no signed score evidence was included in the underwriting input"
778                    .to_string(),
779            remediation: Some(UnderwritingRemediation::ManualReview),
780            evidence_refs: Vec::new(),
781        });
782    }
783
784    if let Some(reputation) = input.reputation.as_ref() {
785        let evidence_refs = input_signal(
786            input.signals.as_slice(),
787            UnderwritingReasonCode::LowReputation,
788        )
789        .map_or_else(Vec::new, |signal| signal.evidence_refs.clone());
790        if reputation.effective_score < policy.deny_reputation_score_below {
791            findings.push(UnderwritingDecisionFinding {
792                class: UnderwritingRiskClass::Critical,
793                outcome: UnderwritingDecisionOutcome::Deny,
794                reason: UnderwritingDecisionReasonCode::ReputationBelowDenyThreshold,
795                signal_reason: Some(UnderwritingReasonCode::LowReputation),
796                description: format!(
797                    "effective reputation score {:.4} is below the deny threshold {:.4}",
798                    reputation.effective_score, policy.deny_reputation_score_below
799                ),
800                remediation: Some(UnderwritingRemediation::ManualReview),
801                evidence_refs,
802            });
803        } else if reputation.effective_score < policy.minimum_approve_reputation_score {
804            findings.push(UnderwritingDecisionFinding {
805                class: UnderwritingRiskClass::Elevated,
806                outcome: UnderwritingDecisionOutcome::ReduceCeiling,
807                reason: UnderwritingDecisionReasonCode::ReputationBelowApproveThreshold,
808                signal_reason: Some(UnderwritingReasonCode::LowReputation),
809                description: format!(
810                    "effective reputation score {:.4} is below the approval threshold {:.4}",
811                    reputation.effective_score, policy.minimum_approve_reputation_score
812                ),
813                remediation: None,
814                evidence_refs,
815            });
816        }
817    }
818
819    if input.receipts.governed_receipts > 0 {
820        let runtime_evidence_refs = runtime_assurance_evidence_refs(
821            input.runtime_assurance.as_ref(),
822            input.signals.as_slice(),
823        );
824        let highest_tier = input
825            .runtime_assurance
826            .as_ref()
827            .and_then(|runtime_assurance| runtime_assurance.highest_tier);
828        match highest_tier {
829            Some(tier) if tier < policy.minimum_step_up_runtime_assurance_tier => {
830                findings.push(UnderwritingDecisionFinding {
831                    class: UnderwritingRiskClass::Elevated,
832                    outcome: UnderwritingDecisionOutcome::StepUp,
833                    reason: UnderwritingDecisionReasonCode::RuntimeAssuranceBelowStepUpTier,
834                    signal_reason: input_signal(
835                        input.signals.as_slice(),
836                        UnderwritingReasonCode::MissingRuntimeAssurance,
837                    )
838                    .map(|signal| signal.reason)
839                    .or_else(|| {
840                        input_signal(
841                            input.signals.as_slice(),
842                            UnderwritingReasonCode::WeakRuntimeAssurance,
843                        )
844                        .map(|signal| signal.reason)
845                    }),
846                    description: format!(
847                        "highest runtime assurance tier `{tier:?}` is below the step-up floor `{}`",
848                        format!("{:?}", policy.minimum_step_up_runtime_assurance_tier)
849                            .to_lowercase()
850                    ),
851                    remediation: Some(UnderwritingRemediation::StrongerRuntimeAssurance),
852                    evidence_refs: runtime_evidence_refs,
853                });
854            }
855            Some(tier) if tier < policy.minimum_approve_runtime_assurance_tier => {
856                findings.push(UnderwritingDecisionFinding {
857                    class: UnderwritingRiskClass::Guarded,
858                    outcome: UnderwritingDecisionOutcome::ReduceCeiling,
859                    reason: UnderwritingDecisionReasonCode::RuntimeAssuranceBelowApproveTier,
860                    signal_reason: input_signal(
861                        input.signals.as_slice(),
862                        UnderwritingReasonCode::WeakRuntimeAssurance,
863                    )
864                    .map(|signal| signal.reason),
865                    description: format!(
866                        "highest runtime assurance tier `{tier:?}` is below the approval target `{}`",
867                        format!("{:?}", policy.minimum_approve_runtime_assurance_tier)
868                            .to_lowercase()
869                    ),
870                    remediation: None,
871                    evidence_refs: runtime_evidence_refs,
872                });
873            }
874            None => {
875                findings.push(UnderwritingDecisionFinding {
876                    class: UnderwritingRiskClass::Elevated,
877                    outcome: UnderwritingDecisionOutcome::StepUp,
878                    reason: UnderwritingDecisionReasonCode::RuntimeAssuranceBelowStepUpTier,
879                    signal_reason: input_signal(
880                        input.signals.as_slice(),
881                        UnderwritingReasonCode::MissingRuntimeAssurance,
882                    )
883                    .map(|signal| signal.reason),
884                    description:
885                        "governed receipt history is present but no runtime assurance evidence was observed"
886                            .to_string(),
887                    remediation: Some(UnderwritingRemediation::StrongerRuntimeAssurance),
888                    evidence_refs: runtime_evidence_refs,
889                });
890            }
891            Some(_) => {}
892        }
893    }
894
895    for signal in &input.signals {
896        match signal.reason {
897            UnderwritingReasonCode::RevokedCertification
898            | UnderwritingReasonCode::FailedCertification
899            | UnderwritingReasonCode::FailedSettlementExposure => {
900                findings.push(UnderwritingDecisionFinding {
901                    class: signal.class,
902                    outcome: UnderwritingDecisionOutcome::Deny,
903                    reason: UnderwritingDecisionReasonCode::PolicySignal,
904                    signal_reason: Some(signal.reason),
905                    description: signal.description.clone(),
906                    remediation: remediation_for_signal(signal.reason),
907                    evidence_refs: signal.evidence_refs.clone(),
908                })
909            }
910            UnderwritingReasonCode::MissingCertification
911                if policy.require_active_tool_certification
912                    && input.filters.tool_server.is_some() =>
913            {
914                findings.push(UnderwritingDecisionFinding {
915                    class: UnderwritingRiskClass::Elevated,
916                    outcome: UnderwritingDecisionOutcome::StepUp,
917                    reason: UnderwritingDecisionReasonCode::PolicySignal,
918                    signal_reason: Some(signal.reason),
919                    description: signal.description.clone(),
920                    remediation: Some(UnderwritingRemediation::ActiveCertification),
921                    evidence_refs: signal.evidence_refs.clone(),
922                });
923            }
924            UnderwritingReasonCode::ProbationaryHistory
925            | UnderwritingReasonCode::ImportedTrustDependency
926            | UnderwritingReasonCode::PendingSettlementExposure
927            | UnderwritingReasonCode::MeteredBillingMismatch
928            | UnderwritingReasonCode::DelegatedCallChain
929            | UnderwritingReasonCode::SharedEvidenceProofRequired => {
930                findings.push(UnderwritingDecisionFinding {
931                    class: signal.class,
932                    outcome: UnderwritingDecisionOutcome::ReduceCeiling,
933                    reason: UnderwritingDecisionReasonCode::PolicySignal,
934                    signal_reason: Some(signal.reason),
935                    description: signal.description.clone(),
936                    remediation: remediation_for_signal(signal.reason),
937                    evidence_refs: signal.evidence_refs.clone(),
938                })
939            }
940            UnderwritingReasonCode::LowReputation
941            | UnderwritingReasonCode::MissingRuntimeAssurance
942            | UnderwritingReasonCode::WeakRuntimeAssurance
943            | UnderwritingReasonCode::MissingCertification => {}
944        }
945    }
946
947    dedupe_findings(&mut findings);
948
949    let outcome = findings
950        .iter()
951        .map(|finding| finding.outcome)
952        .max()
953        .unwrap_or(UnderwritingDecisionOutcome::Approve);
954    let risk_class = findings
955        .iter()
956        .map(|finding| finding.class)
957        .max()
958        .unwrap_or(UnderwritingRiskClass::Baseline);
959    let suggested_ceiling_factor = (outcome == UnderwritingDecisionOutcome::ReduceCeiling)
960        .then_some(policy.reduce_ceiling_factor);
961
962    Ok(UnderwritingDecisionReport {
963        schema: UNDERWRITING_DECISION_REPORT_SCHEMA.to_string(),
964        generated_at: input.generated_at,
965        policy: policy.clone(),
966        outcome,
967        risk_class,
968        suggested_ceiling_factor,
969        findings,
970        input,
971    })
972}
973
974pub fn build_underwriting_decision_artifact(
975    evaluation: UnderwritingDecisionReport,
976    issued_at: u64,
977    supersedes_decision_id: Option<String>,
978    quoted_exposure: Option<MonetaryAmount>,
979) -> Result<UnderwritingDecisionArtifact, String> {
980    let review_state = match evaluation.outcome {
981        UnderwritingDecisionOutcome::Approve | UnderwritingDecisionOutcome::ReduceCeiling => {
982            UnderwritingReviewState::Approved
983        }
984        UnderwritingDecisionOutcome::StepUp => UnderwritingReviewState::ManualReviewRequired,
985        UnderwritingDecisionOutcome::Deny => UnderwritingReviewState::Denied,
986    };
987    let budget =
988        budget_recommendation_for_outcome(evaluation.outcome, evaluation.suggested_ceiling_factor);
989    let premium =
990        premium_quote_for_outcome(evaluation.outcome, evaluation.risk_class, quoted_exposure);
991    let decision_id_input = canonical_json_bytes(&(
992        UNDERWRITING_DECISION_ARTIFACT_SCHEMA,
993        issued_at,
994        &evaluation,
995        &supersedes_decision_id,
996        &budget,
997        &premium,
998    ))
999    .map_err(|error| error.to_string())?;
1000    let decision_id = format!("uwd-{}", sha256_hex(&decision_id_input));
1001
1002    Ok(UnderwritingDecisionArtifact {
1003        schema: UNDERWRITING_DECISION_ARTIFACT_SCHEMA.to_string(),
1004        decision_id,
1005        issued_at,
1006        evaluation,
1007        lifecycle_state: UnderwritingDecisionLifecycleState::Active,
1008        review_state,
1009        supersedes_decision_id,
1010        budget,
1011        premium,
1012    })
1013}
1014
1015fn budget_recommendation_for_outcome(
1016    outcome: UnderwritingDecisionOutcome,
1017    ceiling_factor: Option<f64>,
1018) -> UnderwritingBudgetRecommendation {
1019    match outcome {
1020        UnderwritingDecisionOutcome::Approve => UnderwritingBudgetRecommendation {
1021            action: UnderwritingBudgetAction::Preserve,
1022            ceiling_factor: None,
1023            rationale: "bounded underwriting approved the existing ceiling".to_string(),
1024        },
1025        UnderwritingDecisionOutcome::ReduceCeiling => UnderwritingBudgetRecommendation {
1026            action: UnderwritingBudgetAction::Reduce,
1027            ceiling_factor,
1028            rationale: "risk findings require a narrower economic ceiling".to_string(),
1029        },
1030        UnderwritingDecisionOutcome::StepUp => UnderwritingBudgetRecommendation {
1031            action: UnderwritingBudgetAction::Hold,
1032            ceiling_factor: None,
1033            rationale: "manual review or stronger evidence is required before granting the ceiling"
1034                .to_string(),
1035        },
1036        UnderwritingDecisionOutcome::Deny => UnderwritingBudgetRecommendation {
1037            action: UnderwritingBudgetAction::Deny,
1038            ceiling_factor: None,
1039            rationale: "bounded underwriting denied the requested economic authority".to_string(),
1040        },
1041    }
1042}
1043
1044fn premium_quote_for_outcome(
1045    outcome: UnderwritingDecisionOutcome,
1046    risk_class: UnderwritingRiskClass,
1047    quoted_exposure: Option<MonetaryAmount>,
1048) -> UnderwritingPremiumQuote {
1049    let basis_points = match outcome {
1050        UnderwritingDecisionOutcome::Approve => Some(match risk_class {
1051            UnderwritingRiskClass::Baseline => 100,
1052            UnderwritingRiskClass::Guarded => 150,
1053            UnderwritingRiskClass::Elevated => 200,
1054            UnderwritingRiskClass::Critical => 300,
1055        }),
1056        UnderwritingDecisionOutcome::ReduceCeiling => Some(match risk_class {
1057            UnderwritingRiskClass::Baseline => 150,
1058            UnderwritingRiskClass::Guarded => 250,
1059            UnderwritingRiskClass::Elevated => 400,
1060            UnderwritingRiskClass::Critical => 600,
1061        }),
1062        UnderwritingDecisionOutcome::StepUp | UnderwritingDecisionOutcome::Deny => None,
1063    };
1064
1065    match outcome {
1066        UnderwritingDecisionOutcome::Approve | UnderwritingDecisionOutcome::ReduceCeiling => {
1067            UnderwritingPremiumQuote {
1068                state: UnderwritingPremiumState::Quoted,
1069                basis_points,
1070                quoted_amount: quoted_exposure
1071                    .as_ref()
1072                    .zip(basis_points)
1073                    .map(|(amount, bps)| quote_premium_amount(amount, bps)),
1074                rationale: "premium output is derived from the bounded decision schedule"
1075                    .to_string(),
1076            }
1077        }
1078        UnderwritingDecisionOutcome::StepUp => UnderwritingPremiumQuote {
1079            state: UnderwritingPremiumState::Withheld,
1080            basis_points: None,
1081            quoted_amount: None,
1082            rationale: "premium is withheld until manual review or stronger evidence completes"
1083                .to_string(),
1084        },
1085        UnderwritingDecisionOutcome::Deny => UnderwritingPremiumQuote {
1086            state: UnderwritingPremiumState::NotApplicable,
1087            basis_points: None,
1088            quoted_amount: None,
1089            rationale: "premium is not quoted for denied underwriting decisions".to_string(),
1090        },
1091    }
1092}
1093
1094fn quote_premium_amount(exposure: &MonetaryAmount, basis_points: u32) -> MonetaryAmount {
1095    let units = (u128::from(exposure.units) * u128::from(basis_points)).div_ceil(10_000_u128);
1096    MonetaryAmount {
1097        units: units as u64,
1098        currency: exposure.currency.clone(),
1099    }
1100}
1101
1102fn remediation_for_signal(reason: UnderwritingReasonCode) -> Option<UnderwritingRemediation> {
1103    match reason {
1104        UnderwritingReasonCode::FailedSettlementExposure
1105        | UnderwritingReasonCode::PendingSettlementExposure => {
1106            Some(UnderwritingRemediation::SettlementResolution)
1107        }
1108        UnderwritingReasonCode::MeteredBillingMismatch => {
1109            Some(UnderwritingRemediation::MeteredBillingReconciliation)
1110        }
1111        UnderwritingReasonCode::RevokedCertification
1112        | UnderwritingReasonCode::FailedCertification
1113        | UnderwritingReasonCode::MissingCertification => {
1114            Some(UnderwritingRemediation::ActiveCertification)
1115        }
1116        _ => None,
1117    }
1118}
1119
1120fn runtime_assurance_evidence_refs(
1121    runtime_assurance: Option<&UnderwritingRuntimeAssuranceEvidence>,
1122    signals: &[UnderwritingSignal],
1123) -> Vec<UnderwritingEvidenceReference> {
1124    if let Some(signal) = input_signal(signals, UnderwritingReasonCode::MissingRuntimeAssurance) {
1125        return signal.evidence_refs.clone();
1126    }
1127    if let Some(signal) = input_signal(signals, UnderwritingReasonCode::WeakRuntimeAssurance) {
1128        return signal.evidence_refs.clone();
1129    }
1130    runtime_assurance
1131        .and_then(|runtime_assurance| {
1132            runtime_assurance
1133                .latest_evidence_sha256
1134                .as_ref()
1135                .map(|evidence_sha256| UnderwritingEvidenceReference {
1136                    kind: UnderwritingEvidenceKind::RuntimeAssuranceEvidence,
1137                    reference_id: evidence_sha256.clone(),
1138                    observed_at: None,
1139                    digest_sha256: Some(evidence_sha256.clone()),
1140                    locator: runtime_assurance
1141                        .latest_verifier
1142                        .as_ref()
1143                        .map(|verifier| format!("runtime-assurance:{verifier}")),
1144                })
1145        })
1146        .into_iter()
1147        .collect()
1148}
1149
1150fn input_signal(
1151    signals: &[UnderwritingSignal],
1152    reason: UnderwritingReasonCode,
1153) -> Option<&UnderwritingSignal> {
1154    signals.iter().find(|signal| signal.reason == reason)
1155}
1156
1157fn dedupe_findings(findings: &mut Vec<UnderwritingDecisionFinding>) {
1158    let mut deduped = Vec::with_capacity(findings.len());
1159    for finding in findings.drain(..) {
1160        let duplicate = deduped
1161            .iter()
1162            .any(|existing: &UnderwritingDecisionFinding| {
1163                existing.outcome == finding.outcome
1164                    && existing.reason == finding.reason
1165                    && existing.signal_reason == finding.signal_reason
1166            });
1167        if !duplicate {
1168            deduped.push(finding);
1169        }
1170    }
1171    *findings = deduped;
1172}
1173
1174#[cfg(test)]
1175#[allow(clippy::unwrap_used)]
1176mod tests {
1177    use super::*;
1178
1179    #[test]
1180    fn underwriting_query_requires_anchor() {
1181        let query = UnderwritingPolicyInputQuery::default();
1182        let error = query.validate().unwrap_err();
1183        assert!(error.contains("at least one anchor"));
1184    }
1185
1186    #[test]
1187    fn underwriting_query_requires_tool_server_when_tool_name_is_set() {
1188        let query = UnderwritingPolicyInputQuery {
1189            tool_name: Some("bash".to_string()),
1190            ..UnderwritingPolicyInputQuery::default()
1191        };
1192        let error = query.validate().unwrap_err();
1193        assert!(error.contains("--tool-server"));
1194    }
1195
1196    #[test]
1197    fn underwriting_query_clamps_limit_and_validates_window() {
1198        let query = UnderwritingPolicyInputQuery {
1199            agent_subject: Some("subject-1".to_string()),
1200            since: Some(20),
1201            until: Some(10),
1202            receipt_limit: Some(5_000),
1203            ..UnderwritingPolicyInputQuery::default()
1204        };
1205        assert_eq!(
1206            query.receipt_limit_or_default(),
1207            MAX_UNDERWRITING_RECEIPT_LIMIT
1208        );
1209        assert_eq!(
1210            query.normalized().receipt_limit,
1211            Some(MAX_UNDERWRITING_RECEIPT_LIMIT)
1212        );
1213        let error = query.validate().unwrap_err();
1214        assert!(error.contains("since > until"));
1215    }
1216
1217    #[test]
1218    fn underwriting_taxonomy_v1_lists_all_supported_classes_and_reasons() {
1219        let taxonomy = UnderwritingRiskTaxonomy::default();
1220        assert_eq!(taxonomy.version, UNDERWRITING_RISK_TAXONOMY_VERSION);
1221        assert!(taxonomy
1222            .supported_classes
1223            .contains(&UnderwritingRiskClass::Critical));
1224        assert!(taxonomy
1225            .supported_reasons
1226            .contains(&UnderwritingReasonCode::MeteredBillingMismatch));
1227    }
1228
1229    fn sample_underwriting_input(generated_at: u64) -> UnderwritingPolicyInput {
1230        UnderwritingPolicyInput {
1231            schema: UNDERWRITING_POLICY_INPUT_SCHEMA.to_string(),
1232            generated_at,
1233            filters: UnderwritingPolicyInputQuery {
1234                agent_subject: Some("subject-1".to_string()),
1235                receipt_limit: Some(10),
1236                ..UnderwritingPolicyInputQuery::default()
1237            },
1238            taxonomy: UnderwritingRiskTaxonomy::default(),
1239            receipts: UnderwritingReceiptEvidence {
1240                matching_receipts: 2,
1241                returned_receipts: 2,
1242                allow_count: 2,
1243                deny_count: 0,
1244                cancelled_count: 0,
1245                incomplete_count: 0,
1246                governed_receipts: 2,
1247                approval_receipts: 2,
1248                approved_receipts: 2,
1249                call_chain_receipts: 0,
1250                runtime_assurance_receipts: 2,
1251                pending_settlement_receipts: 0,
1252                failed_settlement_receipts: 0,
1253                actionable_settlement_receipts: 0,
1254                metered_receipts: 0,
1255                actionable_metered_receipts: 0,
1256                shared_evidence_reference_count: 0,
1257                shared_evidence_proof_required_count: 0,
1258                receipt_refs: vec![
1259                    UnderwritingEvidenceReference {
1260                        kind: UnderwritingEvidenceKind::Receipt,
1261                        reference_id: "rcpt-1".to_string(),
1262                        observed_at: Some(generated_at - 120),
1263                        digest_sha256: None,
1264                        locator: Some("receipt:rcpt-1".to_string()),
1265                    },
1266                    UnderwritingEvidenceReference {
1267                        kind: UnderwritingEvidenceKind::Receipt,
1268                        reference_id: "rcpt-2".to_string(),
1269                        observed_at: Some(generated_at - 30),
1270                        digest_sha256: None,
1271                        locator: Some("receipt:rcpt-2".to_string()),
1272                    },
1273                ],
1274            },
1275            reputation: Some(UnderwritingReputationEvidence {
1276                subject_key: "subject-1".to_string(),
1277                effective_score: 0.93,
1278                probationary: false,
1279                resolved_tier: Some("trusted".to_string()),
1280                imported_signal_count: 0,
1281                accepted_imported_signal_count: 0,
1282            }),
1283            certification: None,
1284            runtime_assurance: Some(UnderwritingRuntimeAssuranceEvidence {
1285                governed_receipts: 2,
1286                runtime_assurance_receipts: 2,
1287                highest_tier: Some(RuntimeAssuranceTier::Verified),
1288                latest_schema: Some("chio.runtime-attestation.azure-maa.jwt.v1".to_string()),
1289                latest_verifier_family: Some(AttestationVerifierFamily::AzureMaa),
1290                latest_verifier: Some("verifier.chio".to_string()),
1291                latest_evidence_sha256: Some("sha256-runtime".to_string()),
1292                observed_verifier_families: vec![AttestationVerifierFamily::AzureMaa],
1293            }),
1294            compliance_score: None,
1295            signals: Vec::new(),
1296        }
1297    }
1298
1299    #[test]
1300    fn underwriting_decision_policy_rejects_invalid_thresholds() {
1301        let policy = UnderwritingDecisionPolicy {
1302            deny_reputation_score_below: 0.8,
1303            minimum_approve_reputation_score: 0.5,
1304            reduce_ceiling_factor: 1.2,
1305            ..UnderwritingDecisionPolicy::default()
1306        };
1307        let error = policy.validate().unwrap_err();
1308        assert!(error.contains("deny_reputation_score_below"));
1309    }
1310
1311    #[test]
1312    fn underwriting_evaluator_approves_recent_high_assurance_history() {
1313        let report = evaluate_underwriting_policy_input(
1314            sample_underwriting_input(1_000_000),
1315            &UnderwritingDecisionPolicy::default(),
1316        )
1317        .unwrap();
1318        assert_eq!(report.schema, UNDERWRITING_DECISION_REPORT_SCHEMA);
1319        assert_eq!(report.outcome, UnderwritingDecisionOutcome::Approve);
1320        assert_eq!(report.risk_class, UnderwritingRiskClass::Baseline);
1321        assert!(report.findings.is_empty());
1322    }
1323
1324    #[test]
1325    fn underwriting_evaluator_reduces_ceiling_for_guarded_signals() {
1326        let mut input = sample_underwriting_input(1_000_000);
1327        input.reputation.as_mut().unwrap().probationary = true;
1328        input.signals.push(UnderwritingSignal {
1329            class: UnderwritingRiskClass::Guarded,
1330            reason: UnderwritingReasonCode::ProbationaryHistory,
1331            description: "local reputation is still probationary".to_string(),
1332            evidence_refs: vec![UnderwritingEvidenceReference {
1333                kind: UnderwritingEvidenceKind::ReputationInspection,
1334                reference_id: "subject-1".to_string(),
1335                observed_at: None,
1336                digest_sha256: None,
1337                locator: Some("reputation:subject-1".to_string()),
1338            }],
1339        });
1340
1341        let report =
1342            evaluate_underwriting_policy_input(input, &UnderwritingDecisionPolicy::default())
1343                .unwrap();
1344        assert_eq!(report.outcome, UnderwritingDecisionOutcome::ReduceCeiling);
1345        assert_eq!(report.risk_class, UnderwritingRiskClass::Guarded);
1346        assert_eq!(report.suggested_ceiling_factor, Some(0.5));
1347        assert_eq!(report.findings.len(), 1);
1348        assert_eq!(
1349            report.findings[0].signal_reason,
1350            Some(UnderwritingReasonCode::ProbationaryHistory)
1351        );
1352    }
1353
1354    #[test]
1355    fn underwriting_evaluator_steps_up_for_stale_history() {
1356        let mut input = sample_underwriting_input(1_000_000);
1357        input.receipts.receipt_refs = vec![UnderwritingEvidenceReference {
1358            kind: UnderwritingEvidenceKind::Receipt,
1359            reference_id: "rcpt-stale".to_string(),
1360            observed_at: Some(100),
1361            digest_sha256: None,
1362            locator: Some("receipt:rcpt-stale".to_string()),
1363        }];
1364        input.receipts.matching_receipts = 1;
1365        let policy = UnderwritingDecisionPolicy {
1366            maximum_receipt_age_seconds: 60,
1367            ..UnderwritingDecisionPolicy::default()
1368        };
1369
1370        let report = evaluate_underwriting_policy_input(input, &policy).unwrap();
1371        assert_eq!(report.outcome, UnderwritingDecisionOutcome::StepUp);
1372        assert!(report.findings.iter().any(|finding| {
1373            finding.reason == UnderwritingDecisionReasonCode::StaleReceiptHistory
1374        }));
1375    }
1376
1377    #[test]
1378    fn underwriting_evaluator_requires_compliance_reference_when_policy_demands_it() {
1379        let report = evaluate_underwriting_policy_input(
1380            sample_underwriting_input(1_000_000),
1381            &UnderwritingDecisionPolicy {
1382                require_compliance_score_reference: true,
1383                ..UnderwritingDecisionPolicy::default()
1384            },
1385        )
1386        .unwrap();
1387
1388        assert_eq!(report.outcome, UnderwritingDecisionOutcome::StepUp);
1389        assert!(report.findings.iter().any(|finding| {
1390            finding.reason == UnderwritingDecisionReasonCode::ComplianceScoreRequired
1391        }));
1392    }
1393
1394    #[test]
1395    fn underwriting_evaluator_denies_critical_signal_history() {
1396        let mut input = sample_underwriting_input(1_000_000);
1397        input.signals.push(UnderwritingSignal {
1398            class: UnderwritingRiskClass::Critical,
1399            reason: UnderwritingReasonCode::FailedSettlementExposure,
1400            description: "one governed receipt remains in failed settlement".to_string(),
1401            evidence_refs: vec![UnderwritingEvidenceReference {
1402                kind: UnderwritingEvidenceKind::SettlementReconciliation,
1403                reference_id: "rcpt-2".to_string(),
1404                observed_at: Some(999_990),
1405                digest_sha256: None,
1406                locator: Some("settlement:rcpt-2".to_string()),
1407            }],
1408        });
1409
1410        let report =
1411            evaluate_underwriting_policy_input(input, &UnderwritingDecisionPolicy::default())
1412                .unwrap();
1413        assert_eq!(report.outcome, UnderwritingDecisionOutcome::Deny);
1414        assert_eq!(report.risk_class, UnderwritingRiskClass::Critical);
1415        assert!(report.findings.iter().any(|finding| {
1416            finding.signal_reason == Some(UnderwritingReasonCode::FailedSettlementExposure)
1417        }));
1418    }
1419
1420    #[test]
1421    fn underwriting_decision_artifact_builds_budget_and_premium_outputs() {
1422        let evaluation = evaluate_underwriting_policy_input(
1423            sample_underwriting_input(1_000_000),
1424            &UnderwritingDecisionPolicy::default(),
1425        )
1426        .unwrap();
1427        let artifact = build_underwriting_decision_artifact(
1428            evaluation,
1429            1_000_100,
1430            None,
1431            Some(MonetaryAmount {
1432                units: 4_200,
1433                currency: "USD".to_string(),
1434            }),
1435        )
1436        .unwrap();
1437        assert_eq!(artifact.schema, UNDERWRITING_DECISION_ARTIFACT_SCHEMA);
1438        assert_eq!(artifact.review_state, UnderwritingReviewState::Approved);
1439        assert_eq!(artifact.budget.action, UnderwritingBudgetAction::Preserve);
1440        assert_eq!(artifact.premium.state, UnderwritingPremiumState::Quoted);
1441        assert_eq!(
1442            artifact.premium.quoted_amount,
1443            Some(MonetaryAmount {
1444                units: 42,
1445                currency: "USD".to_string(),
1446            })
1447        );
1448    }
1449
1450    #[test]
1451    fn signed_underwriting_decision_verifies() {
1452        let evaluation = evaluate_underwriting_policy_input(
1453            sample_underwriting_input(1_000_000),
1454            &UnderwritingDecisionPolicy::default(),
1455        )
1456        .unwrap();
1457        let artifact =
1458            build_underwriting_decision_artifact(evaluation, 1_000_100, None, None).unwrap();
1459        let keypair = crate::crypto::Keypair::generate();
1460        let signed = SignedUnderwritingDecision::sign(artifact, &keypair).unwrap();
1461        assert!(signed.verify_signature().unwrap());
1462    }
1463}