Skip to main content

chio_kernel/
receipt_query.rs

1use crate::receipt_store::StoredToolReceipt;
2
3/// Maximum number of receipts returnable in a single query page.
4pub const MAX_QUERY_LIMIT: usize = 200;
5
6/// Explicit receipt read boundary for query, export, and report surfaces.
7#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
8#[serde(rename_all = "snake_case", tag = "kind")]
9pub enum ReceiptReadBoundary {
10    /// Caller has explicit administrative access to all receipt rows.
11    AdminAll,
12    /// Caller is scoped to a single authenticated tenant.
13    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/// Provenance for a resolved read boundary.
26#[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/// Fully resolved receipt read context. Remote/control-plane callers must
35/// construct this from authenticated context before reaching store queries.
36#[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    /// Explicit switch for including untenanted (NULL tenant_id) rows.
42    /// Local-operator reads set this true; tenant-scoped remote reads
43    /// keep it false so NULL tenant rows stay hidden.
44    #[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/// Query parameters for filtering and paginating tool receipts.
94#[derive(Debug, Default, Clone)]
95pub struct ReceiptQuery {
96    /// Filter by capability ID (exact match).
97    pub capability_id: Option<String>,
98    /// Filter by tool server name (exact match).
99    pub tool_server: Option<String>,
100    /// Filter by tool name (exact match).
101    pub tool_name: Option<String>,
102    /// Filter by decision outcome (maps to decision_kind column:
103    /// "allow", "deny", "cancelled", "incomplete").
104    pub outcome: Option<String>,
105    /// Include only receipts with timestamp >= since (Unix seconds, inclusive).
106    pub since: Option<u64>,
107    /// Include only receipts with timestamp <= until (Unix seconds, inclusive).
108    pub until: Option<u64>,
109    /// Include only receipts with financial cost_charged >= min_cost (minor units).
110    /// Receipts without financial metadata are excluded when this filter is set.
111    pub min_cost: Option<u64>,
112    /// Include only receipts with financial cost_charged <= max_cost (minor units).
113    /// Receipts without financial metadata are excluded when this filter is set.
114    pub max_cost: Option<u64>,
115    /// Currency for cost filters. Required when either cost bound is present.
116    pub cost_currency: Option<String>,
117    /// Cursor for forward pagination: return only receipts with seq > cursor (exclusive).
118    pub cursor: Option<u64>,
119    /// Maximum number of receipts to return per page (capped at MAX_QUERY_LIMIT).
120    pub limit: usize,
121    /// Filter by agent subject public key (hex-encoded Ed25519). Resolved through
122    /// capability_lineage JOIN -- does not replay issuance logs.
123    pub agent_subject: Option<String>,
124    /// Optional tenant narrowing filter. This is never authority by itself:
125    /// callers must also provide a matching explicit `read_context`.
126    pub tenant_filter: Option<String>,
127    /// Explicit read context resolved from authenticated authority.
128    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/// Result of a receipt query, including pagination state.
314#[derive(Debug)]
315pub struct ReceiptQueryResult {
316    /// Receipts matching the query filters, ordered by seq ASC.
317    pub receipts: Vec<StoredToolReceipt>,
318    /// Total number of receipts matching the filters (independent of limit/cursor).
319    pub total_count: u64,
320    /// Cursor for the next page: Some(last_seq) when more results exist, None on last page.
321    pub next_cursor: Option<u64>,
322}