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