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