1use crate::receipt_store::StoredToolReceipt;
2
3pub const MAX_QUERY_LIMIT: usize = 200;
5
6#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
8#[serde(rename_all = "snake_case", tag = "kind")]
9pub enum ReceiptReadBoundary {
10 AdminAll,
12 TenantScoped { tenant: String },
14}
15
16impl ReceiptReadBoundary {
17 #[must_use]
18 pub fn tenant_scoped(tenant: impl Into<String>) -> Self {
19 Self::TenantScoped {
20 tenant: tenant.into(),
21 }
22 }
23}
24
25#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum ReceiptReadContextSource {
29 LocalOperator,
30 AdminService,
31 AuthenticatedTenant,
32}
33
34#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "camelCase")]
38pub struct ReceiptReadContext {
39 pub boundary: ReceiptReadBoundary,
40 pub source: ReceiptReadContextSource,
41 #[serde(default)]
45 pub include_null_tenant: bool,
46}
47
48impl ReceiptReadContext {
49 #[must_use]
50 pub fn local_operator_admin_all() -> Self {
51 Self {
52 boundary: ReceiptReadBoundary::AdminAll,
53 source: ReceiptReadContextSource::LocalOperator,
54 include_null_tenant: true,
55 }
56 }
57
58 #[must_use]
59 pub fn admin_service() -> Self {
60 Self {
61 boundary: ReceiptReadBoundary::AdminAll,
62 source: ReceiptReadContextSource::AdminService,
63 include_null_tenant: false,
64 }
65 }
66
67 #[must_use]
68 pub fn authenticated_tenant(tenant: impl Into<String>) -> Self {
69 Self {
70 boundary: ReceiptReadBoundary::tenant_scoped(tenant),
71 source: ReceiptReadContextSource::AuthenticatedTenant,
72 include_null_tenant: false,
73 }
74 }
75
76 #[must_use]
77 pub fn local_operator_tenant(tenant: impl Into<String>) -> Self {
78 Self {
79 boundary: ReceiptReadBoundary::tenant_scoped(tenant),
80 source: ReceiptReadContextSource::LocalOperator,
81 include_null_tenant: true,
82 }
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct EffectiveReceiptReadScope {
88 pub tenant: Option<String>,
89 pub include_null_tenant: bool,
90 pub is_admin_all: bool,
91}
92
93#[derive(Debug, Default, Clone)]
95pub struct ReceiptQuery {
96 pub capability_id: Option<String>,
98 pub tool_server: Option<String>,
100 pub tool_name: Option<String>,
102 pub outcome: Option<String>,
105 pub since: Option<u64>,
107 pub until: Option<u64>,
109 pub min_cost: Option<u64>,
112 pub max_cost: Option<u64>,
115 pub cost_currency: Option<String>,
117 pub cursor: Option<u64>,
119 pub limit: usize,
121 pub agent_subject: Option<String>,
124 pub tenant_filter: Option<String>,
127 pub read_context: Option<ReceiptReadContext>,
129}
130
131impl ReceiptQuery {
132 pub fn validated_cost_currency(&self) -> Result<Option<&str>, String> {
133 if self
134 .min_cost
135 .zip(self.max_cost)
136 .is_some_and(|(minimum, maximum)| minimum > maximum)
137 {
138 return Err("receipt query minimum cost exceeds maximum cost".to_string());
139 }
140 let has_cost_bound = self.min_cost.is_some() || self.max_cost.is_some();
141 let Some(currency) = self.cost_currency.as_deref() else {
142 if has_cost_bound {
143 return Err("receipt query cost bounds require a currency".to_string());
144 }
145 return Ok(None);
146 };
147 if currency.len() != 3 || !currency.bytes().all(|byte| byte.is_ascii_uppercase()) {
148 return Err("receipt query currency must be a three-letter uppercase code".to_string());
149 }
150 Ok(Some(currency))
151 }
152
153 #[must_use]
154 pub fn with_read_context(mut self, read_context: ReceiptReadContext) -> Self {
155 self.read_context = Some(read_context);
156 self
157 }
158
159 #[must_use]
160 pub fn local_operator_admin(mut self) -> Self {
161 self.read_context = Some(ReceiptReadContext::local_operator_admin_all());
162 self
163 }
164
165 #[must_use]
166 pub fn authenticated_tenant(mut self, tenant: impl Into<String>) -> Self {
167 self.read_context = Some(ReceiptReadContext::authenticated_tenant(tenant));
168 self
169 }
170
171 pub fn effective_read_scope(&self) -> Result<EffectiveReceiptReadScope, String> {
172 self.validated_cost_currency()?;
173 if let Some(context) = &self.read_context {
174 return match &context.boundary {
175 ReceiptReadBoundary::AdminAll => {
176 let tenant = self.tenant_filter.as_deref().map(str::trim);
177 if tenant.is_some_and(str::is_empty) {
178 return Err(
179 "receipt query tenant filter requires a non-empty tenant".to_string()
180 );
181 }
182 Ok(EffectiveReceiptReadScope {
183 tenant: tenant.map(ToOwned::to_owned),
184 include_null_tenant: tenant.is_none() && context.include_null_tenant,
185 is_admin_all: true,
186 })
187 }
188 ReceiptReadBoundary::TenantScoped { tenant } => {
189 let tenant = tenant.trim();
190 if tenant.is_empty() {
191 return Err(
192 "tenant-scoped receipt query requires a non-empty tenant".to_string()
193 );
194 }
195 if self
196 .tenant_filter
197 .as_deref()
198 .is_some_and(|filter| filter != tenant)
199 {
200 return Err(
201 "receipt query tenant filter cannot widen authenticated tenant scope"
202 .to_string(),
203 );
204 }
205 Ok(EffectiveReceiptReadScope {
206 tenant: Some(tenant.to_string()),
207 include_null_tenant: context.include_null_tenant,
208 is_admin_all: false,
209 })
210 }
211 };
212 }
213
214 Err("receipt query requires an explicit read context".to_string())
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::{ReceiptQuery, ReceiptReadBoundary, ReceiptReadContext, ReceiptReadContextSource};
221
222 #[test]
223 fn tenant_filter_without_read_context_is_not_authority() {
224 let query = ReceiptQuery {
225 tenant_filter: Some("tenant-a".to_string()),
226 ..ReceiptQuery::default()
227 };
228
229 let err = query
230 .effective_read_scope()
231 .expect_err("tenant_filter must not authorize a receipt read by itself");
232
233 assert_eq!(err, "receipt query requires an explicit read context");
234 }
235
236 #[test]
237 fn authenticated_tenant_context_must_match_query_filter() {
238 let query = ReceiptQuery {
239 tenant_filter: Some("tenant-b".to_string()),
240 read_context: Some(ReceiptReadContext::authenticated_tenant("tenant-a")),
241 ..ReceiptQuery::default()
242 };
243
244 let err = query
245 .effective_read_scope()
246 .expect_err("query filter must not widen authenticated tenant scope");
247
248 assert_eq!(
249 err,
250 "receipt query tenant filter cannot widen authenticated tenant scope"
251 );
252 }
253
254 #[test]
255 fn admin_context_tenant_filter_narrows_effective_scope() {
256 let query = ReceiptQuery {
257 tenant_filter: Some("tenant-a".to_string()),
258 read_context: Some(ReceiptReadContext::admin_service()),
259 ..ReceiptQuery::default()
260 };
261
262 let scope = query
263 .effective_read_scope()
264 .expect("admin tenant filter should narrow the query");
265
266 assert_eq!(scope.tenant.as_deref(), Some("tenant-a"));
267 assert!(!scope.include_null_tenant);
268 assert!(scope.is_admin_all);
269 }
270
271 #[test]
272 fn admin_context_rejects_blank_tenant_filter() {
273 let query = ReceiptQuery {
274 tenant_filter: Some(" ".to_string()),
275 read_context: Some(ReceiptReadContext::admin_service()),
276 ..ReceiptQuery::default()
277 };
278
279 let err = query
280 .effective_read_scope()
281 .expect_err("blank tenant filter must fail closed");
282
283 assert_eq!(
284 err,
285 "receipt query tenant filter requires a non-empty tenant"
286 );
287 }
288
289 #[test]
290 fn tenant_scoped_context_rejects_blank_boundary_tenant() {
291 let query = ReceiptQuery {
292 read_context: Some(ReceiptReadContext {
293 boundary: ReceiptReadBoundary::TenantScoped {
294 tenant: " ".to_string(),
295 },
296 source: ReceiptReadContextSource::AuthenticatedTenant,
297 include_null_tenant: false,
298 }),
299 ..ReceiptQuery::default()
300 };
301
302 let err = query
303 .effective_read_scope()
304 .expect_err("blank tenant boundary must fail closed");
305
306 assert_eq!(
307 err,
308 "tenant-scoped receipt query requires a non-empty tenant"
309 );
310 }
311}
312
313#[derive(Debug)]
315pub struct ReceiptQueryResult {
316 pub receipts: Vec<StoredToolReceipt>,
318 pub total_count: u64,
320 pub next_cursor: Option<u64>,
322}