Skip to main content

chio_kernel/operator_report/
queries.rs

1use super::*;
2
3/// Filter surface for the operator-facing reporting API.
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
5#[serde(rename_all = "camelCase")]
6pub struct OperatorReportQuery {
7    #[serde(default, skip_serializing_if = "Option::is_none")]
8    pub capability_id: Option<String>,
9    #[serde(default, skip_serializing_if = "Option::is_none")]
10    pub agent_subject: Option<String>,
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub tool_server: Option<String>,
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub tool_name: Option<String>,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub since: Option<u64>,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub until: Option<u64>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub group_limit: Option<usize>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub time_bucket: Option<AnalyticsTimeBucket>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub attribution_limit: Option<usize>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub budget_limit: Option<usize>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub settlement_limit: Option<usize>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub metered_limit: Option<usize>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub authorization_limit: Option<usize>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub economic_limit: Option<usize>,
35    /// Auth-derived read authority. This is never accepted from request bodies.
36    #[serde(skip)]
37    pub read_context: Option<ReceiptReadContext>,
38}
39
40impl Default for OperatorReportQuery {
41    fn default() -> Self {
42        Self {
43            capability_id: None,
44            agent_subject: None,
45            tool_server: None,
46            tool_name: None,
47            since: None,
48            until: None,
49            group_limit: Some(50),
50            time_bucket: Some(AnalyticsTimeBucket::Day),
51            attribution_limit: Some(100),
52            budget_limit: Some(50),
53            settlement_limit: Some(50),
54            metered_limit: Some(50),
55            authorization_limit: Some(50),
56            economic_limit: Some(50),
57            read_context: None,
58        }
59    }
60}
61
62impl OperatorReportQuery {
63    #[must_use]
64    pub fn to_receipt_analytics_query(&self) -> crate::ReceiptAnalyticsQuery {
65        crate::ReceiptAnalyticsQuery {
66            capability_id: self.capability_id.clone(),
67            agent_subject: self.agent_subject.clone(),
68            tool_server: self.tool_server.clone(),
69            tool_name: self.tool_name.clone(),
70            since: self.since,
71            until: self.until,
72            group_limit: self.group_limit,
73            time_bucket: self.time_bucket,
74            read_context: self.read_context.clone(),
75        }
76    }
77
78    /// Project the shared operator filters into an exposure-ledger query.
79    ///
80    /// Every field is constructed explicitly so that a newly added `ExposureLedgerQuery`
81    /// field fails to compile here rather than being silently omitted.
82    #[must_use]
83    pub fn to_exposure_ledger_query(&self) -> chio_credit::ExposureLedgerQuery {
84        chio_credit::ExposureLedgerQuery {
85            capability_id: self.capability_id.clone(),
86            agent_subject: self.agent_subject.clone(),
87            tool_server: self.tool_server.clone(),
88            tool_name: self.tool_name.clone(),
89            since: self.since,
90            until: self.until,
91            receipt_limit: None,
92            decision_limit: None,
93        }
94    }
95
96    #[must_use]
97    pub fn to_cost_attribution_query(&self) -> CostAttributionQuery {
98        CostAttributionQuery {
99            capability_id: self.capability_id.clone(),
100            agent_subject: self.agent_subject.clone(),
101            tool_server: self.tool_server.clone(),
102            tool_name: self.tool_name.clone(),
103            since: self.since,
104            until: self.until,
105            limit: self.attribution_limit,
106            read_context: self.read_context.clone(),
107        }
108    }
109
110    pub fn to_evidence_export_query(&self) -> Result<EvidenceExportQuery, String> {
111        let (tenant, read_boundary) = match self.read_context.as_ref() {
112            Some(ReceiptReadContext {
113                boundary: ReceiptReadBoundary::AdminAll,
114                ..
115            }) => (None, Some(ReceiptReadBoundary::AdminAll)),
116            Some(ReceiptReadContext {
117                boundary: ReceiptReadBoundary::TenantScoped { tenant },
118                ..
119            }) => (
120                Some(tenant.clone()),
121                Some(ReceiptReadBoundary::tenant_scoped(tenant.clone())),
122            ),
123            None => {
124                return Err(
125                    "operator report evidence export requires an explicit read context".to_string(),
126                );
127            }
128        };
129        Ok(EvidenceExportQuery {
130            capability_id: self.capability_id.clone(),
131            agent_subject: self.agent_subject.clone(),
132            since: self.since,
133            until: self.until,
134            tenant,
135            read_boundary,
136        })
137    }
138
139    #[must_use]
140    pub fn direct_evidence_export_supported(&self) -> bool {
141        self.tool_server.is_none() && self.tool_name.is_none()
142    }
143
144    #[must_use]
145    pub fn budget_limit_or_default(&self) -> usize {
146        self.budget_limit
147            .unwrap_or(50)
148            .clamp(1, MAX_OPERATOR_BUDGET_LIMIT)
149    }
150
151    #[must_use]
152    pub fn settlement_limit_or_default(&self) -> usize {
153        self.settlement_limit
154            .unwrap_or(50)
155            .clamp(1, MAX_SETTLEMENT_BACKLOG_LIMIT)
156    }
157
158    #[must_use]
159    pub fn metered_limit_or_default(&self) -> usize {
160        self.metered_limit
161            .unwrap_or(50)
162            .clamp(1, MAX_METERED_BILLING_LIMIT)
163    }
164
165    #[must_use]
166    pub fn authorization_limit_or_default(&self) -> usize {
167        self.authorization_limit
168            .unwrap_or(50)
169            .clamp(1, MAX_AUTHORIZATION_CONTEXT_LIMIT)
170    }
171
172    #[must_use]
173    pub fn economic_limit_or_default(&self) -> usize {
174        self.economic_limit
175            .unwrap_or(50)
176            .clamp(1, MAX_ECONOMIC_RECEIPT_LIMIT)
177    }
178
179    #[must_use]
180    pub fn to_shared_evidence_query(&self) -> SharedEvidenceQuery {
181        SharedEvidenceQuery {
182            capability_id: self.capability_id.clone(),
183            agent_subject: self.agent_subject.clone(),
184            tool_server: self.tool_server.clone(),
185            tool_name: self.tool_name.clone(),
186            since: self.since,
187            until: self.until,
188            issuer: None,
189            partner: None,
190            limit: self.group_limit,
191            read_context: self.read_context.clone(),
192        }
193    }
194}
195
196/// Filter surface for the signed insurer/risk behavioral feed export.
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
198#[serde(rename_all = "camelCase")]
199pub struct BehavioralFeedQuery {
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub capability_id: Option<String>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub agent_subject: Option<String>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub tool_server: Option<String>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub tool_name: Option<String>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub since: Option<u64>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub until: Option<u64>,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub receipt_limit: Option<usize>,
214    /// Auth-derived read authority. This is never accepted from request bodies.
215    #[serde(skip)]
216    pub read_context: Option<ReceiptReadContext>,
217}
218
219impl Default for BehavioralFeedQuery {
220    fn default() -> Self {
221        Self {
222            capability_id: None,
223            agent_subject: None,
224            tool_server: None,
225            tool_name: None,
226            since: None,
227            until: None,
228            receipt_limit: Some(100),
229            read_context: None,
230        }
231    }
232}
233
234impl BehavioralFeedQuery {
235    #[must_use]
236    pub fn receipt_limit_or_default(&self) -> usize {
237        self.receipt_limit
238            .unwrap_or(100)
239            .clamp(1, MAX_BEHAVIORAL_FEED_RECEIPT_LIMIT)
240    }
241
242    #[must_use]
243    pub fn normalized(&self) -> Self {
244        let mut normalized = self.clone();
245        normalized.receipt_limit = Some(self.receipt_limit_or_default());
246        normalized
247    }
248
249    #[must_use]
250    pub fn to_operator_report_query(&self) -> OperatorReportQuery {
251        OperatorReportQuery {
252            capability_id: self.capability_id.clone(),
253            agent_subject: self.agent_subject.clone(),
254            tool_server: self.tool_server.clone(),
255            tool_name: self.tool_name.clone(),
256            since: self.since,
257            until: self.until,
258            read_context: self.read_context.clone(),
259            ..OperatorReportQuery::default()
260        }
261    }
262
263    #[must_use]
264    pub fn to_receipt_query(&self) -> ReceiptQuery {
265        ReceiptQuery {
266            capability_id: self.capability_id.clone(),
267            tool_server: self.tool_server.clone(),
268            tool_name: self.tool_name.clone(),
269            outcome: None,
270            since: self.since,
271            until: self.until,
272            min_cost: None,
273            max_cost: None,
274            cost_currency: None,
275            cursor: None,
276            limit: self.receipt_limit_or_default(),
277            agent_subject: self.agent_subject.clone(),
278            tenant_filter: None,
279            read_context: self.read_context.clone(),
280        }
281    }
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
285#[serde(rename_all = "camelCase")]
286pub struct SharedEvidenceQuery {
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub capability_id: Option<String>,
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub agent_subject: Option<String>,
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub tool_server: Option<String>,
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub tool_name: Option<String>,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub since: Option<u64>,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub until: Option<u64>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub issuer: Option<String>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub partner: Option<String>,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub limit: Option<usize>,
305    /// Auth-derived read authority. This is never accepted from request bodies.
306    #[serde(skip)]
307    pub read_context: Option<ReceiptReadContext>,
308}
309
310impl Default for SharedEvidenceQuery {
311    fn default() -> Self {
312        Self {
313            capability_id: None,
314            agent_subject: None,
315            tool_server: None,
316            tool_name: None,
317            since: None,
318            until: None,
319            issuer: None,
320            partner: None,
321            limit: Some(50),
322            read_context: None,
323        }
324    }
325}
326
327impl SharedEvidenceQuery {
328    #[must_use]
329    pub fn limit_or_default(&self) -> usize {
330        self.limit.unwrap_or(50).clamp(1, MAX_SHARED_EVIDENCE_LIMIT)
331    }
332}
333
334#[cfg(test)]
335mod exposure_query_tests {
336    use super::*;
337
338    #[test]
339    fn to_exposure_ledger_query_threads_shared_filters() {
340        let query = OperatorReportQuery {
341            capability_id: Some("cap-abc".to_string()),
342            agent_subject: Some("subject-hex".to_string()),
343            tool_server: Some("shell".to_string()),
344            tool_name: Some("bash".to_string()),
345            since: Some(10),
346            until: Some(99),
347            ..OperatorReportQuery::default()
348        };
349        let exposure = query.to_exposure_ledger_query();
350        assert_eq!(exposure.capability_id.as_deref(), Some("cap-abc"));
351        assert_eq!(exposure.agent_subject.as_deref(), Some("subject-hex"));
352        assert_eq!(exposure.tool_server.as_deref(), Some("shell"));
353        assert_eq!(exposure.tool_name.as_deref(), Some("bash"));
354        assert_eq!(exposure.since, Some(10));
355        assert_eq!(exposure.until, Some(99));
356        assert_eq!(exposure.receipt_limit, None);
357        assert_eq!(exposure.decision_limit, None);
358    }
359}