Skip to main content

chio_underwriting/
decision.rs

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