Skip to main content

chio_autonomy/
validation.rs

1use std::collections::HashSet;
2
3use crate::capability::scope::MonetaryAmount;
4use crate::error::AutonomyContractError;
5use crate::model::*;
6
7pub fn validate_autonomous_pricing_input(
8    input: &AutonomousPricingInputArtifact,
9) -> Result<(), AutonomyContractError> {
10    if input.schema != CHIO_AUTONOMOUS_PRICING_INPUT_SCHEMA {
11        return Err(AutonomyContractError::UnsupportedSchema(
12            input.schema.clone(),
13        ));
14    }
15    ensure_non_empty(&input.input_id, "autonomous_pricing_input.input_id")?;
16    ensure_non_empty(&input.subject_key, "autonomous_pricing_input.subject_key")?;
17    ensure_non_empty(&input.provider_id, "autonomous_pricing_input.provider_id")?;
18    validate_currency_code(&input.currency, "autonomous_pricing_input.currency")?;
19    validate_positive_money(
20        &input.requested_coverage_amount,
21        "autonomous_pricing_input.requested_coverage_amount",
22    )?;
23    if !money_currency_matches_declared(&input.requested_coverage_amount, &input.currency) {
24        return Err(AutonomyContractError::InvalidDecision(
25            "autonomous pricing input coverage amount currency must match currency".to_string(),
26        ));
27    }
28    if input.receipt_history_window_secs == 0 {
29        return Err(AutonomyContractError::InvalidDecision(
30            "autonomous pricing input receipt_history_window_secs must be non-zero".to_string(),
31        ));
32    }
33    if input.reputation_score_bps > 10_000 {
34        return Err(AutonomyContractError::InvalidDecision(
35            "autonomous pricing input reputation_score_bps must be <= 10000".to_string(),
36        ));
37    }
38    if input.available_capital_units == 0 {
39        return Err(AutonomyContractError::InvalidDecision(
40            "autonomous pricing input available_capital_units must be non-zero".to_string(),
41        ));
42    }
43    if input.evidence_refs.is_empty() {
44        return Err(AutonomyContractError::MissingField(
45            "autonomous_pricing_input.evidence_refs",
46        ));
47    }
48
49    let mut ids = HashSet::new();
50    let mut kinds = HashSet::new();
51    for evidence in &input.evidence_refs {
52        ensure_non_empty(
53            &evidence.reference_id,
54            "autonomous_pricing_input.evidence_refs.reference_id",
55        )?;
56        if !ids.insert(evidence.reference_id.as_str()) {
57            return Err(AutonomyContractError::DuplicateValue(
58                evidence.reference_id.clone(),
59            ));
60        }
61        kinds.insert(evidence.kind);
62    }
63    for required in [
64        AutonomousEvidenceKind::UnderwritingDecision,
65        AutonomousEvidenceKind::ExposureLedger,
66        AutonomousEvidenceKind::CreditScorecard,
67        AutonomousEvidenceKind::CapitalBook,
68    ] {
69        if !kinds.contains(&required) {
70            return Err(AutonomyContractError::UnknownReference(format!(
71                "autonomous pricing input missing required evidence {:?}",
72                required
73            )));
74        }
75    }
76    if (input.pending_loss_units > 0 || input.settled_loss_units > 0)
77        && !kinds.contains(&AutonomousEvidenceKind::CreditLossLifecycle)
78    {
79        return Err(AutonomyContractError::UnknownReference(
80            "autonomous pricing input with loss units must include credit_loss_lifecycle evidence"
81                .to_string(),
82        ));
83    }
84    if input.latest_web3_settlement_state.is_some()
85        && !kinds.contains(&AutonomousEvidenceKind::Web3SettlementReceipt)
86    {
87        return Err(AutonomyContractError::UnknownReference(
88            "autonomous pricing input with latest_web3_settlement_state must include web3_settlement_receipt evidence"
89                .to_string(),
90        ));
91    }
92    Ok(())
93}
94
95pub fn validate_autonomous_pricing_authority_envelope(
96    envelope: &AutonomousPricingAuthorityEnvelopeArtifact,
97) -> Result<(), AutonomyContractError> {
98    if envelope.schema != CHIO_AUTONOMOUS_PRICING_AUTHORITY_ENVELOPE_SCHEMA {
99        return Err(AutonomyContractError::UnsupportedSchema(
100            envelope.schema.clone(),
101        ));
102    }
103    ensure_non_empty(
104        &envelope.envelope_id,
105        "autonomous_authority_envelope.envelope_id",
106    )?;
107    ensure_non_empty(
108        &envelope.subject_key,
109        "autonomous_authority_envelope.subject_key",
110    )?;
111    ensure_non_empty(
112        &envelope.provider_id,
113        "autonomous_authority_envelope.provider_id",
114    )?;
115    validate_currency_code(&envelope.currency, "autonomous_authority_envelope.currency")?;
116    if envelope.permitted_actions.is_empty() {
117        return Err(AutonomyContractError::MissingField(
118            "autonomous_authority_envelope.permitted_actions",
119        ));
120    }
121    ensure_unique_copy_values(
122        &envelope.permitted_actions,
123        "autonomous_authority_envelope.permitted_actions",
124    )?;
125    if envelope.authority_chain_refs.is_empty() {
126        return Err(AutonomyContractError::MissingField(
127            "autonomous_authority_envelope.authority_chain_refs",
128        ));
129    }
130    ensure_unique_strings(
131        &envelope.authority_chain_refs,
132        "autonomous_authority_envelope.authority_chain_refs",
133    )?;
134    validate_positive_money(
135        &envelope.max_coverage_amount,
136        "autonomous_authority_envelope.max_coverage_amount",
137    )?;
138    validate_positive_money(
139        &envelope.max_premium_amount,
140        "autonomous_authority_envelope.max_premium_amount",
141    )?;
142    if !money_currency_matches_declared(&envelope.max_coverage_amount, &envelope.currency) {
143        return Err(AutonomyContractError::InvalidEnvelope(
144            "autonomous authority max_coverage_amount currency must match envelope currency"
145                .to_string(),
146        ));
147    }
148    if !money_currency_matches_declared(&envelope.max_premium_amount, &envelope.currency) {
149        return Err(AutonomyContractError::InvalidEnvelope(
150            "autonomous authority max_premium_amount currency must match envelope currency"
151                .to_string(),
152        ));
153    }
154    if envelope.max_rate_change_bps == 0 || envelope.max_rate_change_bps > 10_000 {
155        return Err(AutonomyContractError::InvalidEnvelope(
156            "autonomous authority max_rate_change_bps must be between 1 and 10000".to_string(),
157        ));
158    }
159    if envelope.max_daily_decisions == 0 {
160        return Err(AutonomyContractError::InvalidEnvelope(
161            "autonomous authority max_daily_decisions must be non-zero".to_string(),
162        ));
163    }
164    if let Some(threshold) = envelope.requires_human_review_above_premium.as_ref() {
165        validate_positive_money(
166            threshold,
167            "autonomous_authority_envelope.requires_human_review_above_premium",
168        )?;
169        if !money_currency_matches_declared(threshold, &envelope.currency) {
170            return Err(AutonomyContractError::InvalidEnvelope(
171                "autonomous authority premium review threshold currency must match envelope currency"
172                    .to_string(),
173            ));
174        }
175        if threshold.units > envelope.max_premium_amount.units {
176            return Err(AutonomyContractError::InvalidEnvelope(
177                "autonomous authority premium review threshold cannot exceed max_premium_amount"
178                    .to_string(),
179            ));
180        }
181    }
182    if envelope.not_before >= envelope.not_after {
183        return Err(AutonomyContractError::InvalidEnvelope(
184            "autonomous authority not_before must be earlier than not_after".to_string(),
185        ));
186    }
187    match envelope.kind {
188        AutonomousAuthorityEnvelopeKind::OperatorPolicy => {}
189        AutonomousAuthorityEnvelopeKind::RegulatedRole => {
190            ensure_non_empty(
191                envelope.regulated_role.as_deref().unwrap_or_default(),
192                "autonomous_authority_envelope.regulated_role",
193            )?;
194        }
195        AutonomousAuthorityEnvelopeKind::DelegatedMarketAuthority => {
196            ensure_non_empty(
197                envelope
198                    .delegated_authority_reference
199                    .as_deref()
200                    .unwrap_or_default(),
201                "autonomous_authority_envelope.delegated_authority_reference",
202            )?;
203        }
204    }
205    if envelope.automation_mode != AutonomousAutomationMode::Active
206        && envelope
207            .permitted_actions
208            .contains(&AutonomousPricingAction::Bind)
209    {
210        return Err(AutonomyContractError::InvalidEnvelope(
211            "only active automation envelopes may permit bind".to_string(),
212        ));
213    }
214    Ok(())
215}
216
217pub fn validate_autonomous_pricing_decision(
218    decision: &AutonomousPricingDecisionArtifact,
219) -> Result<(), AutonomyContractError> {
220    if decision.schema != CHIO_AUTONOMOUS_PRICING_DECISION_SCHEMA {
221        return Err(AutonomyContractError::UnsupportedSchema(
222            decision.schema.clone(),
223        ));
224    }
225    ensure_non_empty(
226        &decision.decision_id,
227        "autonomous_pricing_decision.decision_id",
228    )?;
229    validate_autonomous_pricing_input(&decision.pricing_input)?;
230    validate_autonomous_pricing_authority_envelope(&decision.authority_envelope)?;
231    validate_model_provenance(&decision.model)?;
232    validate_positive_money(
233        &decision.suggested_coverage_amount,
234        "autonomous_pricing_decision.suggested_coverage_amount",
235    )?;
236    validate_positive_money(
237        &decision.suggested_premium_amount,
238        "autonomous_pricing_decision.suggested_premium_amount",
239    )?;
240    if !money_currency_matches_declared(
241        &decision.suggested_coverage_amount,
242        &decision.authority_envelope.currency,
243    ) || !money_currency_matches_declared(
244        &decision.suggested_premium_amount,
245        &decision.authority_envelope.currency,
246    ) {
247        return Err(AutonomyContractError::InvalidDecision(
248            "autonomous pricing decision money fields must match envelope currency".to_string(),
249        ));
250    }
251    if decision.pricing_input.subject_key != decision.authority_envelope.subject_key
252        || decision.pricing_input.provider_id != decision.authority_envelope.provider_id
253        || decision.pricing_input.currency != decision.authority_envelope.currency
254    {
255        return Err(AutonomyContractError::InvalidDecision(
256            "autonomous pricing decision input must match authority envelope subject/provider/currency"
257                .to_string(),
258        ));
259    }
260    if decision.suggested_coverage_amount.units
261        > decision.authority_envelope.max_coverage_amount.units
262    {
263        return Err(AutonomyContractError::InvalidDecision(
264            "autonomous pricing decision coverage exceeds authority envelope".to_string(),
265        ));
266    }
267    if decision.suggested_premium_amount.units
268        > decision.authority_envelope.max_premium_amount.units
269    {
270        return Err(AutonomyContractError::InvalidDecision(
271            "autonomous pricing decision premium exceeds authority envelope".to_string(),
272        ));
273    }
274    if let Some(ceiling_factor_bps) = decision.suggested_ceiling_factor_bps {
275        if ceiling_factor_bps == 0 || ceiling_factor_bps > 10_000 {
276            return Err(AutonomyContractError::InvalidDecision(
277                "autonomous pricing decision suggested_ceiling_factor_bps must be between 1 and 10000"
278                    .to_string(),
279            ));
280        }
281    }
282    if decision.confidence_bps == 0 || decision.confidence_bps > 10_000 {
283        return Err(AutonomyContractError::InvalidDecision(
284            "autonomous pricing decision confidence_bps must be between 1 and 10000".to_string(),
285        ));
286    }
287    if decision.explanation_factors.is_empty() {
288        return Err(AutonomyContractError::MissingField(
289            "autonomous_pricing_decision.explanation_factors",
290        ));
291    }
292    let mut factor_codes = HashSet::new();
293    for factor in &decision.explanation_factors {
294        ensure_non_empty(
295            &factor.code,
296            "autonomous_pricing_decision.explanation_factors.code",
297        )?;
298        ensure_non_empty(
299            &factor.description,
300            "autonomous_pricing_decision.explanation_factors.description",
301        )?;
302        if factor.weight_bps == 0 || factor.weight_bps > 10_000 {
303            return Err(AutonomyContractError::InvalidDecision(
304                "autonomous pricing explanation factor weight_bps must be between 1 and 10000"
305                    .to_string(),
306            ));
307        }
308        if !factor_codes.insert(factor.code.as_str()) {
309            return Err(AutonomyContractError::DuplicateValue(factor.code.clone()));
310        }
311    }
312    if decision.authority_envelope.automation_mode == AutonomousAutomationMode::Shadow
313        && decision.review_state != AutonomousDecisionReviewState::ShadowOnly
314    {
315        return Err(AutonomyContractError::InvalidDecision(
316            "shadow automation decisions must use review_state shadow_only".to_string(),
317        ));
318    }
319    if decision.review_state == AutonomousDecisionReviewState::ShadowOnly
320        && decision.authority_envelope.automation_mode != AutonomousAutomationMode::Shadow
321    {
322        return Err(AutonomyContractError::InvalidDecision(
323            "review_state shadow_only requires a shadow automation envelope".to_string(),
324        ));
325    }
326    if decision.disposition == AutonomousPricingDisposition::ManualReview
327        && decision.review_state == AutonomousDecisionReviewState::AutoApproved
328    {
329        return Err(AutonomyContractError::InvalidDecision(
330            "manual-review pricing decisions cannot be auto approved".to_string(),
331        ));
332    }
333    if let Some(threshold) = decision
334        .authority_envelope
335        .requires_human_review_above_premium
336        .as_ref()
337    {
338        if decision.review_state == AutonomousDecisionReviewState::AutoApproved
339            && decision.suggested_premium_amount.units > threshold.units
340        {
341            return Err(AutonomyContractError::InvalidDecision(
342                "auto-approved pricing decisions cannot exceed the premium review threshold"
343                    .to_string(),
344            ));
345        }
346    }
347    if decision.disposition == AutonomousPricingDisposition::BindWithinEnvelope {
348        if !decision
349            .authority_envelope
350            .permitted_actions
351            .contains(&AutonomousPricingAction::Bind)
352        {
353            return Err(AutonomyContractError::InvalidDecision(
354                "bind-within-envelope decisions require bind permission in the authority envelope"
355                    .to_string(),
356            ));
357        }
358        if !decision
359            .authority_envelope
360            .support_boundary
361            .live_bind_supported
362        {
363            return Err(AutonomyContractError::InvalidDecision(
364                "bind-within-envelope decisions require live_bind_supported".to_string(),
365            ));
366        }
367        if decision.authority_envelope.requires_human_review_for_bind
368            && decision.review_state == AutonomousDecisionReviewState::AutoApproved
369        {
370            return Err(AutonomyContractError::InvalidDecision(
371                "bind-within-envelope decisions cannot be auto approved when human review is required for bind"
372                    .to_string(),
373            ));
374        }
375    }
376    Ok(())
377}
378
379pub fn validate_capital_pool_optimization(
380    optimization: &CapitalPoolOptimizationArtifact,
381) -> Result<(), AutonomyContractError> {
382    if optimization.schema != CHIO_CAPITAL_POOL_OPTIMIZATION_SCHEMA {
383        return Err(AutonomyContractError::UnsupportedSchema(
384            optimization.schema.clone(),
385        ));
386    }
387    ensure_non_empty(
388        &optimization.optimization_id,
389        "capital_pool_optimization.optimization_id",
390    )?;
391    ensure_non_empty(
392        &optimization.subject_key,
393        "capital_pool_optimization.subject_key",
394    )?;
395    validate_currency_code(&optimization.currency, "capital_pool_optimization.currency")?;
396    ensure_non_empty(
397        &optimization.pricing_decision_ref,
398        "capital_pool_optimization.pricing_decision_ref",
399    )?;
400    ensure_non_empty(
401        &optimization.capital_book_ref,
402        "capital_pool_optimization.capital_book_ref",
403    )?;
404    if optimization.facility_refs.is_empty() {
405        return Err(AutonomyContractError::MissingField(
406            "capital_pool_optimization.facility_refs",
407        ));
408    }
409    ensure_unique_strings(
410        &optimization.facility_refs,
411        "capital_pool_optimization.facility_refs",
412    )?;
413    ensure_unique_strings(
414        &optimization.pending_claim_refs,
415        "capital_pool_optimization.pending_claim_refs",
416    )?;
417    if optimization.target_reserve_ratio_bps == 0 || optimization.target_reserve_ratio_bps > 10_000
418    {
419        return Err(AutonomyContractError::InvalidOptimization(
420            "capital pool optimization target_reserve_ratio_bps must be between 1 and 10000"
421                .to_string(),
422        ));
423    }
424    if optimization.max_facility_utilization_bps == 0
425        || optimization.max_facility_utilization_bps > 10_000
426    {
427        return Err(AutonomyContractError::InvalidOptimization(
428            "capital pool optimization max_facility_utilization_bps must be between 1 and 10000"
429                .to_string(),
430        ));
431    }
432    if optimization.max_bind_capacity_units == 0 {
433        return Err(AutonomyContractError::InvalidOptimization(
434            "capital pool optimization max_bind_capacity_units must be non-zero".to_string(),
435        ));
436    }
437    if optimization.recommendations.is_empty() {
438        return Err(AutonomyContractError::MissingField(
439            "capital_pool_optimization.recommendations",
440        ));
441    }
442    for recommendation in &optimization.recommendations {
443        ensure_non_empty(
444            &recommendation.source_ref,
445            "capital_pool_optimization.recommendations.source_ref",
446        )?;
447        ensure_non_empty(
448            &recommendation.rationale,
449            "capital_pool_optimization.recommendations.rationale",
450        )?;
451        validate_positive_money(
452            &recommendation.amount,
453            "capital_pool_optimization.recommendations.amount",
454        )?;
455        if !money_currency_matches_declared(&recommendation.amount, &optimization.currency) {
456            return Err(AutonomyContractError::InvalidOptimization(
457                "capital pool optimization recommendation amounts must match optimization currency"
458                    .to_string(),
459            ));
460        }
461        if recommendation.action == CapitalOptimizationAction::ShiftCapacity
462            && recommendation
463                .destination_ref
464                .as_deref()
465                .is_none_or(|value| value.trim().is_empty())
466        {
467            return Err(AutonomyContractError::InvalidOptimization(
468                "shift-capacity recommendations require destination_ref".to_string(),
469            ));
470        }
471    }
472    Ok(())
473}
474
475pub fn validate_capital_pool_simulation_report(
476    report: &CapitalPoolSimulationReport,
477) -> Result<(), AutonomyContractError> {
478    if report.schema != CHIO_CAPITAL_POOL_SIMULATION_REPORT_SCHEMA {
479        return Err(AutonomyContractError::UnsupportedSchema(
480            report.schema.clone(),
481        ));
482    }
483    ensure_non_empty(
484        &report.simulation_id,
485        "capital_pool_simulation.simulation_id",
486    )?;
487    ensure_non_empty(&report.subject_key, "capital_pool_simulation.subject_key")?;
488    validate_currency_code(&report.currency, "capital_pool_simulation.currency")?;
489    validate_capital_pool_optimization(&report.baseline_optimization)?;
490    validate_capital_pool_optimization(&report.candidate_optimization)?;
491    if report.baseline_optimization.subject_key != report.subject_key
492        || report.candidate_optimization.subject_key != report.subject_key
493    {
494        return Err(AutonomyContractError::InvalidOptimization(
495            "capital pool simulation subject_key must match both baseline and candidate optimizations"
496                .to_string(),
497        ));
498    }
499    if report.baseline_optimization.currency != report.currency
500        || report.candidate_optimization.currency != report.currency
501    {
502        return Err(AutonomyContractError::InvalidOptimization(
503            "capital pool simulation currency must match both baseline and candidate optimizations"
504                .to_string(),
505        ));
506    }
507    if report.baseline_optimization.optimization_id == report.candidate_optimization.optimization_id
508    {
509        return Err(AutonomyContractError::DuplicateValue(
510            report.baseline_optimization.optimization_id.clone(),
511        ));
512    }
513    if !report
514        .baseline_optimization
515        .support_boundary
516        .scenario_comparison_supported
517        || !report
518            .candidate_optimization
519            .support_boundary
520            .scenario_comparison_supported
521    {
522        return Err(AutonomyContractError::InvalidOptimization(
523            "capital pool simulation requires scenario_comparison_supported on both optimizations"
524                .to_string(),
525        ));
526    }
527    if report.deltas.is_empty() {
528        return Err(AutonomyContractError::MissingField(
529            "capital_pool_simulation.deltas",
530        ));
531    }
532    for delta in &report.deltas {
533        ensure_non_empty(
534            &delta.metric_name,
535            "capital_pool_simulation.deltas.metric_name",
536        )?;
537        ensure_non_empty(
538            &delta.description,
539            "capital_pool_simulation.deltas.description",
540        )?;
541    }
542    ensure_non_empty(
543        &report.recommended_operator_action,
544        "capital_pool_simulation.recommended_operator_action",
545    )?;
546    Ok(())
547}
548
549pub fn validate_autonomous_execution_decision(
550    decision: &AutonomousExecutionDecisionArtifact,
551) -> Result<(), AutonomyContractError> {
552    if decision.schema != CHIO_AUTONOMOUS_EXECUTION_DECISION_SCHEMA {
553        return Err(AutonomyContractError::UnsupportedSchema(
554            decision.schema.clone(),
555        ));
556    }
557    for (value, field) in [
558        (&decision.execution_id, "autonomous_execution.execution_id"),
559        (
560            &decision.pricing_decision_ref,
561            "autonomous_execution.pricing_decision_ref",
562        ),
563        (
564            &decision.optimization_ref,
565            "autonomous_execution.optimization_ref",
566        ),
567        (
568            &decision.authority_envelope_ref,
569            "autonomous_execution.authority_envelope_ref",
570        ),
571        (&decision.subject_key, "autonomous_execution.subject_key"),
572        (&decision.provider_id, "autonomous_execution.provider_id"),
573    ] {
574        ensure_non_empty(value, field)?;
575    }
576    validate_currency_code(&decision.currency, "autonomous_execution.currency")?;
577    if decision.safety_gates.is_empty() {
578        return Err(AutonomyContractError::MissingField(
579            "autonomous_execution.safety_gates",
580        ));
581    }
582    let mut gate_names = HashSet::new();
583    for gate in &decision.safety_gates {
584        ensure_non_empty(&gate.name, "autonomous_execution.safety_gates.name")?;
585        ensure_non_empty(
586            &gate.description,
587            "autonomous_execution.safety_gates.description",
588        )?;
589        if !gate_names.insert(gate.name.as_str()) {
590            return Err(AutonomyContractError::DuplicateValue(gate.name.clone()));
591        }
592    }
593    validate_rollback_control(&decision.rollback_control)?;
594
595    let all_gates_passed = decision.safety_gates.iter().all(|gate| gate.passed);
596    if decision.lifecycle_state == AutonomousExecutionLifecycleState::Executed && !all_gates_passed
597    {
598        return Err(AutonomyContractError::InvalidExecution(
599            "executed autonomous actions require all safety gates to pass".to_string(),
600        ));
601    }
602    if decision.lifecycle_state == AutonomousExecutionLifecycleState::Blocked && all_gates_passed {
603        return Err(AutonomyContractError::InvalidExecution(
604            "blocked autonomous actions require at least one failed safety gate".to_string(),
605        ));
606    }
607
608    match decision.action {
609        AutonomousExecutionAction::Reprice | AutonomousExecutionAction::Renew => {
610            ensure_non_empty(
611                decision.quote_response_ref.as_deref().unwrap_or_default(),
612                "autonomous_execution.quote_response_ref",
613            )?;
614            if decision.auto_bind_decision_ref.is_some()
615                || decision.bound_coverage_ref.is_some()
616                || decision.settlement_dispatch_ref.is_some()
617            {
618                return Err(AutonomyContractError::InvalidExecution(
619                    "reprice and renew automation cannot embed bind or settlement references"
620                        .to_string(),
621                ));
622            }
623        }
624        AutonomousExecutionAction::Decline => {
625            if decision.auto_bind_decision_ref.is_some()
626                || decision.bound_coverage_ref.is_some()
627                || decision.settlement_dispatch_ref.is_some()
628            {
629                return Err(AutonomyContractError::InvalidExecution(
630                    "decline automation cannot embed bind or settlement references".to_string(),
631                ));
632            }
633        }
634        AutonomousExecutionAction::Bind => {
635            ensure_non_empty(
636                decision.quote_response_ref.as_deref().unwrap_or_default(),
637                "autonomous_execution.quote_response_ref",
638            )?;
639            ensure_non_empty(
640                decision
641                    .auto_bind_decision_ref
642                    .as_deref()
643                    .unwrap_or_default(),
644                "autonomous_execution.auto_bind_decision_ref",
645            )?;
646            ensure_non_empty(
647                decision.bound_coverage_ref.as_deref().unwrap_or_default(),
648                "autonomous_execution.bound_coverage_ref",
649            )?;
650            if decision.lifecycle_state == AutonomousExecutionLifecycleState::Executed {
651                ensure_non_empty(
652                    decision
653                        .settlement_dispatch_ref
654                        .as_deref()
655                        .unwrap_or_default(),
656                    "autonomous_execution.settlement_dispatch_ref",
657                )?;
658            }
659        }
660    }
661
662    Ok(())
663}
664
665pub fn validate_autonomous_comparison_report(
666    report: &AutonomousComparisonReport,
667) -> Result<(), AutonomyContractError> {
668    if report.schema != CHIO_AUTONOMOUS_COMPARISON_REPORT_SCHEMA {
669        return Err(AutonomyContractError::UnsupportedSchema(
670            report.schema.clone(),
671        ));
672    }
673    ensure_non_empty(&report.comparison_id, "autonomous_comparison.comparison_id")?;
674    ensure_non_empty(
675        &report.pricing_decision_ref,
676        "autonomous_comparison.pricing_decision_ref",
677    )?;
678    ensure_non_empty(
679        &report.manual_decision_ref,
680        "autonomous_comparison.manual_decision_ref",
681    )?;
682    if report.disposition != AutonomousComparisonDisposition::Match && report.deltas.is_empty() {
683        return Err(AutonomyContractError::MissingField(
684            "autonomous_comparison.deltas",
685        ));
686    }
687    if report.disposition == AutonomousComparisonDisposition::ManualOverride {
688        ensure_non_empty(
689            report.override_reference.as_deref().unwrap_or_default(),
690            "autonomous_comparison.override_reference",
691        )?;
692    }
693    for delta in &report.deltas {
694        for (value, field) in [
695            (&delta.field, "autonomous_comparison.deltas.field"),
696            (
697                &delta.automated_value,
698                "autonomous_comparison.deltas.automated_value",
699            ),
700            (
701                &delta.manual_value,
702                "autonomous_comparison.deltas.manual_value",
703            ),
704            (
705                &delta.description,
706                "autonomous_comparison.deltas.description",
707            ),
708        ] {
709            ensure_non_empty(value, field)?;
710        }
711    }
712    Ok(())
713}
714
715pub fn validate_autonomous_drift_report(
716    report: &AutonomousDriftReport,
717) -> Result<(), AutonomyContractError> {
718    if report.schema != CHIO_AUTONOMOUS_DRIFT_REPORT_SCHEMA {
719        return Err(AutonomyContractError::UnsupportedSchema(
720            report.schema.clone(),
721        ));
722    }
723    ensure_non_empty(&report.drift_report_id, "autonomous_drift.drift_report_id")?;
724    ensure_non_empty(&report.subject_key, "autonomous_drift.subject_key")?;
725    ensure_non_empty(
726        &report.pricing_decision_ref,
727        "autonomous_drift.pricing_decision_ref",
728    )?;
729    ensure_non_empty(
730        &report.optimization_ref,
731        "autonomous_drift.optimization_ref",
732    )?;
733    validate_autonomous_rollback_plan(&report.rollback_plan)?;
734    validate_autonomous_comparison_report(&report.comparison_report)?;
735    if report.drift_signals.is_empty() {
736        return Err(AutonomyContractError::MissingField(
737            "autonomous_drift.drift_signals",
738        ));
739    }
740    let mut critical_kinds = HashSet::new();
741    for signal in &report.drift_signals {
742        ensure_non_empty(&signal.metric_name, "autonomous_drift.metric_name")?;
743        ensure_non_empty(&signal.description, "autonomous_drift.description")?;
744        if signal.threshold_value == 0 {
745            return Err(AutonomyContractError::InvalidDrift(
746                "autonomous drift threshold_value must be non-zero".to_string(),
747            ));
748        }
749        if signal.severity == AutonomousDriftSeverity::Critical {
750            critical_kinds.insert(signal.kind);
751        }
752    }
753    if !critical_kinds.is_empty() && !report.fail_safe_engaged {
754        return Err(AutonomyContractError::InvalidDrift(
755            "critical drift signals require fail_safe_engaged".to_string(),
756        ));
757    }
758    for kind in critical_kinds {
759        if !report.rollback_plan.triggers.contains(&kind) {
760            return Err(AutonomyContractError::InvalidDrift(format!(
761                "rollback plan does not cover critical drift trigger {:?}",
762                kind
763            )));
764        }
765    }
766    Ok(())
767}
768
769pub fn validate_autonomous_rollback_plan(
770    plan: &AutonomousRollbackPlanArtifact,
771) -> Result<(), AutonomyContractError> {
772    if plan.schema != CHIO_AUTONOMOUS_ROLLBACK_PLAN_SCHEMA {
773        return Err(AutonomyContractError::UnsupportedSchema(
774            plan.schema.clone(),
775        ));
776    }
777    ensure_non_empty(&plan.plan_id, "autonomous_rollback.plan_id")?;
778    ensure_non_empty(&plan.subject_key, "autonomous_rollback.subject_key")?;
779    if plan.triggers.is_empty() {
780        return Err(AutonomyContractError::MissingField(
781            "autonomous_rollback.triggers",
782        ));
783    }
784    if plan.actions.is_empty() {
785        return Err(AutonomyContractError::MissingField(
786            "autonomous_rollback.actions",
787        ));
788    }
789    ensure_unique_copy_values(&plan.triggers, "autonomous_rollback.triggers")?;
790    ensure_unique_copy_values(&plan.actions, "autonomous_rollback.actions")?;
791    Ok(())
792}
793
794pub fn validate_autonomous_qualification_matrix(
795    matrix: &AutonomousQualificationMatrix,
796) -> Result<(), AutonomyContractError> {
797    if matrix.schema != CHIO_AUTONOMOUS_QUALIFICATION_MATRIX_SCHEMA {
798        return Err(AutonomyContractError::UnsupportedSchema(
799            matrix.schema.clone(),
800        ));
801    }
802    ensure_non_empty(&matrix.profile_id, "autonomous_qualification.profile_id")?;
803    if matrix.cases.is_empty() {
804        return Err(AutonomyContractError::MissingField(
805            "autonomous_qualification.cases",
806        ));
807    }
808    let mut seen_ids = HashSet::new();
809    for case in &matrix.cases {
810        ensure_non_empty(&case.id, "autonomous_qualification.cases.id")?;
811        ensure_non_empty(&case.name, "autonomous_qualification.cases.name")?;
812        ensure_non_empty(&case.notes, "autonomous_qualification.cases.notes")?;
813        if case.requirement_ids.is_empty() {
814            return Err(AutonomyContractError::InvalidQualificationCase(format!(
815                "qualification case `{}` requires at least one requirement id",
816                case.id
817            )));
818        }
819        ensure_unique_strings(
820            &case.requirement_ids,
821            "autonomous_qualification.cases.requirement_ids",
822        )?;
823        if !seen_ids.insert(case.id.as_str()) {
824            return Err(AutonomyContractError::DuplicateValue(case.id.clone()));
825        }
826    }
827    Ok(())
828}
829
830fn validate_model_provenance(
831    model: &AutonomousModelProvenance,
832) -> Result<(), AutonomyContractError> {
833    for (value, field) in [
834        (&model.model_id, "autonomous_model.model_id"),
835        (&model.model_version, "autonomous_model.model_version"),
836        (&model.engine_family, "autonomous_model.engine_family"),
837        (&model.input_hash, "autonomous_model.input_hash"),
838        (
839            &model.explanation_version,
840            "autonomous_model.explanation_version",
841        ),
842    ] {
843        ensure_non_empty(value, field)?;
844    }
845    if model.training_cutoff > model.published_at {
846        return Err(AutonomyContractError::InvalidDecision(
847            "autonomous model training_cutoff must be <= published_at".to_string(),
848        ));
849    }
850    Ok(())
851}
852
853fn validate_rollback_control(
854    control: &AutonomousExecutionRollbackControl,
855) -> Result<(), AutonomyContractError> {
856    ensure_non_empty(
857        &control.rollback_plan_ref,
858        "autonomous_execution.rollback_control.rollback_plan_ref",
859    )?;
860    ensure_non_empty(
861        &control.human_interrupt_contact,
862        "autonomous_execution.rollback_control.human_interrupt_contact",
863    )?;
864    Ok(())
865}
866
867fn validate_positive_money(
868    amount: &MonetaryAmount,
869    field: &'static str,
870) -> Result<(), AutonomyContractError> {
871    if amount.units == 0 {
872        return Err(AutonomyContractError::InvalidDecision(format!(
873            "{field} must be greater than zero"
874        )));
875    }
876    validate_currency_code(&amount.currency, field)
877}
878
879pub(super) fn money_currency_matches_declared(
880    amount: &MonetaryAmount,
881    declared_currency: &str,
882) -> bool {
883    amount.currency == declared_currency
884}
885
886fn validate_currency_code(
887    currency: &str,
888    field: &'static str,
889) -> Result<(), AutonomyContractError> {
890    if currency.len() != 3
891        || !currency
892            .chars()
893            .all(|character| character.is_ascii_uppercase())
894    {
895        return Err(AutonomyContractError::InvalidDecision(format!(
896            "{field} must be a 3-letter uppercase currency code"
897        )));
898    }
899    Ok(())
900}
901
902fn ensure_non_empty(value: &str, field: &'static str) -> Result<(), AutonomyContractError> {
903    if value.trim().is_empty() {
904        return Err(AutonomyContractError::MissingField(field));
905    }
906    Ok(())
907}
908
909pub(super) fn ensure_unique_strings(
910    values: &[String],
911    field: &'static str,
912) -> Result<(), AutonomyContractError> {
913    let mut seen = HashSet::new();
914    for value in values {
915        ensure_non_empty(value, field)?;
916        if value.trim() != value {
917            return Err(AutonomyContractError::InvalidDecision(format!(
918                "{field} must not contain values with surrounding whitespace"
919            )));
920        }
921        if !seen.insert(value.as_str()) {
922            return Err(AutonomyContractError::DuplicateValue(format!(
923                "{field}:{value}"
924            )));
925        }
926    }
927    Ok(())
928}
929
930fn ensure_unique_copy_values<T>(
931    values: &[T],
932    field: &'static str,
933) -> Result<(), AutonomyContractError>
934where
935    T: Copy + Eq + std::hash::Hash + std::fmt::Debug,
936{
937    let mut seen = HashSet::new();
938    for value in values {
939        if !seen.insert(*value) {
940            return Err(AutonomyContractError::DuplicateValue(format!(
941                "{field}:{value:?}"
942            )));
943        }
944    }
945    Ok(())
946}