use crate::receipt_store::StoredToolReceipt;
pub const MAX_QUERY_LIMIT: usize = 200;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum ReceiptReadBoundary {
AdminAll,
TenantScoped { tenant: String },
}
impl ReceiptReadBoundary {
#[must_use]
pub fn tenant_scoped(tenant: impl Into<String>) -> Self {
Self::TenantScoped {
tenant: tenant.into(),
}
}
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReceiptReadContextSource {
LocalOperator,
AdminService,
AuthenticatedTenant,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ReceiptReadContext {
pub boundary: ReceiptReadBoundary,
pub source: ReceiptReadContextSource,
#[serde(default)]
pub include_null_tenant: bool,
}
impl ReceiptReadContext {
#[must_use]
pub fn local_operator_admin_all() -> Self {
Self {
boundary: ReceiptReadBoundary::AdminAll,
source: ReceiptReadContextSource::LocalOperator,
include_null_tenant: true,
}
}
#[must_use]
pub fn admin_service() -> Self {
Self {
boundary: ReceiptReadBoundary::AdminAll,
source: ReceiptReadContextSource::AdminService,
include_null_tenant: false,
}
}
#[must_use]
pub fn authenticated_tenant(tenant: impl Into<String>) -> Self {
Self {
boundary: ReceiptReadBoundary::tenant_scoped(tenant),
source: ReceiptReadContextSource::AuthenticatedTenant,
include_null_tenant: false,
}
}
#[must_use]
pub fn local_operator_tenant(tenant: impl Into<String>) -> Self {
Self {
boundary: ReceiptReadBoundary::tenant_scoped(tenant),
source: ReceiptReadContextSource::LocalOperator,
include_null_tenant: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EffectiveReceiptReadScope {
pub tenant: Option<String>,
pub include_null_tenant: bool,
pub is_admin_all: bool,
}
#[derive(Debug, Default, Clone)]
pub struct ReceiptQuery {
pub capability_id: Option<String>,
pub tool_server: Option<String>,
pub tool_name: Option<String>,
pub outcome: Option<String>,
pub since: Option<u64>,
pub until: Option<u64>,
pub min_cost: Option<u64>,
pub max_cost: Option<u64>,
pub cost_currency: Option<String>,
pub cursor: Option<u64>,
pub limit: usize,
pub agent_subject: Option<String>,
pub tenant_filter: Option<String>,
pub read_context: Option<ReceiptReadContext>,
}
impl ReceiptQuery {
pub fn validated_cost_currency(&self) -> Result<Option<&str>, String> {
if self
.min_cost
.zip(self.max_cost)
.is_some_and(|(minimum, maximum)| minimum > maximum)
{
return Err("receipt query minimum cost exceeds maximum cost".to_string());
}
let has_cost_bound = self.min_cost.is_some() || self.max_cost.is_some();
let Some(currency) = self.cost_currency.as_deref() else {
if has_cost_bound {
return Err("receipt query cost bounds require a currency".to_string());
}
return Ok(None);
};
if currency.len() != 3 || !currency.bytes().all(|byte| byte.is_ascii_uppercase()) {
return Err("receipt query currency must be a three-letter uppercase code".to_string());
}
Ok(Some(currency))
}
#[must_use]
pub fn with_read_context(mut self, read_context: ReceiptReadContext) -> Self {
self.read_context = Some(read_context);
self
}
#[must_use]
pub fn local_operator_admin(mut self) -> Self {
self.read_context = Some(ReceiptReadContext::local_operator_admin_all());
self
}
#[must_use]
pub fn authenticated_tenant(mut self, tenant: impl Into<String>) -> Self {
self.read_context = Some(ReceiptReadContext::authenticated_tenant(tenant));
self
}
pub fn effective_read_scope(&self) -> Result<EffectiveReceiptReadScope, String> {
self.validated_cost_currency()?;
if let Some(context) = &self.read_context {
return match &context.boundary {
ReceiptReadBoundary::AdminAll => {
let tenant = self.tenant_filter.as_deref().map(str::trim);
if tenant.is_some_and(str::is_empty) {
return Err(
"receipt query tenant filter requires a non-empty tenant".to_string()
);
}
Ok(EffectiveReceiptReadScope {
tenant: tenant.map(ToOwned::to_owned),
include_null_tenant: tenant.is_none() && context.include_null_tenant,
is_admin_all: true,
})
}
ReceiptReadBoundary::TenantScoped { tenant } => {
let tenant = tenant.trim();
if tenant.is_empty() {
return Err(
"tenant-scoped receipt query requires a non-empty tenant".to_string()
);
}
if self
.tenant_filter
.as_deref()
.is_some_and(|filter| filter != tenant)
{
return Err(
"receipt query tenant filter cannot widen authenticated tenant scope"
.to_string(),
);
}
Ok(EffectiveReceiptReadScope {
tenant: Some(tenant.to_string()),
include_null_tenant: context.include_null_tenant,
is_admin_all: false,
})
}
};
}
Err("receipt query requires an explicit read context".to_string())
}
}
#[cfg(test)]
mod tests {
use super::{ReceiptQuery, ReceiptReadBoundary, ReceiptReadContext, ReceiptReadContextSource};
#[test]
fn tenant_filter_without_read_context_is_not_authority() {
let query = ReceiptQuery {
tenant_filter: Some("tenant-a".to_string()),
..ReceiptQuery::default()
};
let err = query
.effective_read_scope()
.expect_err("tenant_filter must not authorize a receipt read by itself");
assert_eq!(err, "receipt query requires an explicit read context");
}
#[test]
fn authenticated_tenant_context_must_match_query_filter() {
let query = ReceiptQuery {
tenant_filter: Some("tenant-b".to_string()),
read_context: Some(ReceiptReadContext::authenticated_tenant("tenant-a")),
..ReceiptQuery::default()
};
let err = query
.effective_read_scope()
.expect_err("query filter must not widen authenticated tenant scope");
assert_eq!(
err,
"receipt query tenant filter cannot widen authenticated tenant scope"
);
}
#[test]
fn admin_context_tenant_filter_narrows_effective_scope() {
let query = ReceiptQuery {
tenant_filter: Some("tenant-a".to_string()),
read_context: Some(ReceiptReadContext::admin_service()),
..ReceiptQuery::default()
};
let scope = query
.effective_read_scope()
.expect("admin tenant filter should narrow the query");
assert_eq!(scope.tenant.as_deref(), Some("tenant-a"));
assert!(!scope.include_null_tenant);
assert!(scope.is_admin_all);
}
#[test]
fn admin_context_rejects_blank_tenant_filter() {
let query = ReceiptQuery {
tenant_filter: Some(" ".to_string()),
read_context: Some(ReceiptReadContext::admin_service()),
..ReceiptQuery::default()
};
let err = query
.effective_read_scope()
.expect_err("blank tenant filter must fail closed");
assert_eq!(
err,
"receipt query tenant filter requires a non-empty tenant"
);
}
#[test]
fn tenant_scoped_context_rejects_blank_boundary_tenant() {
let query = ReceiptQuery {
read_context: Some(ReceiptReadContext {
boundary: ReceiptReadBoundary::TenantScoped {
tenant: " ".to_string(),
},
source: ReceiptReadContextSource::AuthenticatedTenant,
include_null_tenant: false,
}),
..ReceiptQuery::default()
};
let err = query
.effective_read_scope()
.expect_err("blank tenant boundary must fail closed");
assert_eq!(
err,
"tenant-scoped receipt query requires a non-empty tenant"
);
}
}
#[derive(Debug)]
pub struct ReceiptQueryResult {
pub receipts: Vec<StoredToolReceipt>,
pub total_count: u64,
pub next_cursor: Option<u64>,
}