1use super::*;
2
3#[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 #[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 #[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#[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 #[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 #[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}