Skip to main content

chio_kernel/operator_report/
comptroller_surface.rs

1//! Unified spend/exposure comptroller surface projection.
2//!
3//! Pure projection over the existing `OperatorReport` (kernel) and
4//! `ExposureLedgerReport` (credit) types. This is the single Rust source of
5//! truth for the `chio.comptroller.surface-report.v1` schema.
6
7use chio_core_types::CHIO_COMPTROLLER_SURFACE_REPORT_V1_SCHEMA;
8use chio_credit::{ExposureLedgerCurrencyPosition, ExposureLedgerReport};
9use serde::{Deserialize, Serialize};
10
11use super::{
12    BudgetUtilizationSummary, OperatorReport, OperatorReportQuery, SettlementReconciliationSummary,
13};
14
15/// Schema id for the comptroller surface projection (re-exported single source of truth).
16pub const COMPTROLLER_SURFACE_REPORT_SCHEMA: &str = CHIO_COMPTROLLER_SURFACE_REPORT_V1_SCHEMA;
17
18/// Allow/deny/cancelled/incomplete decision counts projected from the operator activity summary.
19#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct ComptrollerDecisionSummary {
22    pub allow_count: u64,
23    pub deny_count: u64,
24    pub cancelled_count: u64,
25    pub incomplete_count: u64,
26}
27
28/// Optional sha256 hash-refs to the composed source artifacts.
29#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct ComptrollerSurfaceSourceRefs {
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub operator_report_ref: Option<String>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub exposure_ledger_ref: Option<String>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub risk_comptroller_report_ref: Option<String>,
38}
39
40/// Unified spend/exposure contract: a projection over OperatorReport + ExposureLedgerReport.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct ComptrollerSurfaceReport {
44    pub schema: String,
45    pub generated_at: u64,
46    pub filters: OperatorReportQuery,
47    pub exposure_positions: Vec<ExposureLedgerCurrencyPosition>,
48    pub decision_summary: ComptrollerDecisionSummary,
49    pub settlement_reconciliation: SettlementReconciliationSummary,
50    pub budget_utilization: BudgetUtilizationSummary,
51    pub source_refs: ComptrollerSurfaceSourceRefs,
52    /// Reserved for future execution-nonce linkage; omitted until a later schema
53    /// revision to avoid a governance-gated schema bump.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub execution_nonce_ref: Option<String>,
56    /// Reserved for future hold linkage; omitted until a later schema revision.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub hold_ref: Option<String>,
59}
60
61impl ComptrollerSurfaceReport {
62    /// Compose the projection from the already-built operator + exposure read models.
63    pub fn from_parts(operator: &OperatorReport, exposure: &ExposureLedgerReport) -> Self {
64        Self {
65            schema: COMPTROLLER_SURFACE_REPORT_SCHEMA.to_string(),
66            generated_at: operator.generated_at,
67            filters: operator.filters.clone(),
68            exposure_positions: exposure.positions.clone(),
69            decision_summary: ComptrollerDecisionSummary {
70                allow_count: operator.activity.summary.allow_count,
71                deny_count: operator.activity.summary.deny_count,
72                cancelled_count: operator.activity.summary.cancelled_count,
73                incomplete_count: operator.activity.summary.incomplete_count,
74            },
75            settlement_reconciliation: operator.settlement_reconciliation.summary.clone(),
76            budget_utilization: operator.budget_utilization.summary.clone(),
77            source_refs: ComptrollerSurfaceSourceRefs::default(),
78            execution_nonce_ref: None,
79            hold_ref: None,
80        }
81    }
82
83    /// Fail-closed single-domain invariant over the credit exposure positions.
84    ///
85    /// Within each currency position, outstanding holds (reserved + pending) must not exceed the
86    /// governed exposure ceiling. A ceiling of 0 means "no governed ceiling" and is skipped
87    /// (fail-safe). This is a credit-domain-only check; it does NOT cross into kernel budget cost
88    /// units, whose unit mapping to exposure units is undefined.
89    pub fn validate_consistency(&self) -> Result<(), String> {
90        for position in &self.exposure_positions {
91            if position.governed_max_exposure_units == 0 {
92                continue;
93            }
94            // Fail-closed: an outstanding sum that overflows u64 must be reported
95            // as an inconsistency, not clamped. A saturating add would pin
96            // `outstanding` to the u64 ceiling and pass the comparison below even
97            // though the true reserved + pending exposure exceeds the governed
98            // limit, masking an over-limit position.
99            let Some(outstanding) = position.reserved_units.checked_add(position.pending_units)
100            else {
101                return Err(format!(
102                    "exposure position {} outstanding holds overflow u64 (reserved {} + pending {})",
103                    position.currency, position.reserved_units, position.pending_units
104                ));
105            };
106            if outstanding > position.governed_max_exposure_units {
107                return Err(format!(
108                    "exposure position {} outstanding holds {} exceed governed ceiling {}",
109                    position.currency, outstanding, position.governed_max_exposure_units
110                ));
111            }
112        }
113        Ok(())
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use chio_credit::ExposureLedgerCurrencyPosition;
121
122    fn position(
123        currency: &str,
124        governed: u64,
125        reserved: u64,
126        pending: u64,
127    ) -> ExposureLedgerCurrencyPosition {
128        ExposureLedgerCurrencyPosition {
129            currency: currency.to_string(),
130            governed_max_exposure_units: governed,
131            reserved_units: reserved,
132            settled_units: 0,
133            pending_units: pending,
134            failed_units: 0,
135            provisional_loss_units: 0,
136            recovered_units: 0,
137            quoted_premium_units: 0,
138            active_quoted_premium_units: 0,
139        }
140    }
141
142    fn sample() -> ComptrollerSurfaceReport {
143        ComptrollerSurfaceReport {
144            schema: COMPTROLLER_SURFACE_REPORT_SCHEMA.to_string(),
145            generated_at: 1_700_000_000,
146            filters: OperatorReportQuery::default(),
147            exposure_positions: vec![position("USD", 4200, 1000, 200)],
148            decision_summary: ComptrollerDecisionSummary {
149                allow_count: 1,
150                deny_count: 1,
151                cancelled_count: 0,
152                incomplete_count: 0,
153            },
154            settlement_reconciliation: SettlementReconciliationSummary::default(),
155            budget_utilization: BudgetUtilizationSummary::default(),
156            source_refs: ComptrollerSurfaceSourceRefs::default(),
157            execution_nonce_ref: None,
158            hold_ref: None,
159        }
160    }
161
162    #[test]
163    fn serde_round_trip_is_camel_case() {
164        let report = sample();
165        let json = serde_json::to_string(&report).expect("serialize");
166        assert!(json.contains("\"schema\":\"chio.comptroller.surface-report.v1\""));
167        assert!(json.contains("\"generatedAt\""));
168        assert!(json.contains("\"exposurePositions\""));
169        assert!(json.contains("\"governedMaxExposureUnits\""));
170        assert!(json.contains("\"allowCount\""));
171        // Reserved linkage slots are omitted until they are populated.
172        assert!(!json.contains("executionNonceRef"));
173        assert!(!json.contains("holdRef"));
174        let back: ComptrollerSurfaceReport = serde_json::from_str(&json).expect("deserialize");
175        assert_eq!(report, back);
176    }
177
178    #[test]
179    fn validate_consistency_accepts_coherent_positions() {
180        assert!(sample().validate_consistency().is_ok());
181    }
182
183    #[test]
184    fn validate_consistency_rejects_outstanding_over_governed_ceiling() {
185        let mut report = sample();
186        report.exposure_positions = vec![position("USD", 4200, 5000, 0)];
187        let err = report
188            .validate_consistency()
189            .expect_err("must reject over-ceiling");
190        assert!(err.contains("exceed governed ceiling"));
191    }
192
193    #[test]
194    fn validate_consistency_treats_zero_ceiling_as_no_ceiling() {
195        let mut report = sample();
196        report.exposure_positions = vec![position("USD", 0, u64::MAX / 2, u64::MAX / 2)];
197        assert!(report.validate_consistency().is_ok());
198    }
199
200    #[test]
201    fn validate_consistency_rejects_outstanding_overflow_over_ceiling() {
202        let mut report = sample();
203        // reserved + pending overflows u64, so a saturating clamp would pin
204        // outstanding to the u64 ceiling and silently pass a governed limit that
205        // the true exposure exceeds. A fail-closed validator must reject it.
206        report.exposure_positions = vec![position("USD", u64::MAX, u64::MAX, 1)];
207        let err = report
208            .validate_consistency()
209            .expect_err("overflowing outstanding must fail closed");
210        assert!(err.contains("overflow"), "unexpected error: {err}");
211    }
212}