use serde::{Deserialize, Serialize};
use crate::capability::ProviderInfo;
use crate::scope::EgressScope;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "id", rename_all = "snake_case")]
pub enum Grantor {
Human(String),
Policy(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConsentReceipt {
pub provider_id: String,
pub scope: EgressScope,
pub provider_name: String,
pub provider_version: String,
pub grantor: Grantor,
pub granted_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
impl ConsentReceipt {
pub fn new(
provider_id: impl Into<String>,
info: &ProviderInfo,
scope: EgressScope,
grantor: Grantor,
granted_at: impl Into<String>,
) -> Self {
Self {
provider_id: provider_id.into(),
scope,
provider_name: info.name.clone(),
provider_version: info.version.clone(),
grantor,
granted_at: granted_at.into(),
expires_at: None,
}
}
pub fn with_expiry(mut self, expires_at: impl Into<String>) -> Self {
self.expires_at = Some(expires_at.into());
self
}
pub fn is_live(&self, now: &str) -> bool {
match &self.expires_at {
Some(expiry) => now < expiry.as_str(),
None => true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capability::DataFlow;
fn info() -> ProviderInfo {
ProviderInfo {
name: "contextgraph-cloud".into(),
version: "0.1.0".into(),
data_flow: DataFlow {
reads: true,
writes: false,
egress: true,
egress_scopes: vec![EgressScope::ThirdPartyModel],
},
}
}
#[test]
fn a_receipt_pins_provider_identity_and_round_trips() {
let receipt = ConsentReceipt::new(
"contextgraph-cloud",
&info(),
EgressScope::ThirdPartyModel,
Grantor::Human("ops@oxagen.sh".into()),
"2026-07-21T00:00:00Z",
);
assert_eq!(receipt.provider_name, "contextgraph-cloud");
assert_eq!(receipt.provider_version, "0.1.0");
let json = serde_json::to_string(&receipt).unwrap();
let back: ConsentReceipt = serde_json::from_str(&json).unwrap();
assert_eq!(back, receipt);
assert!(json.contains("\"kind\":\"human\""));
assert!(json.contains("\"id\":\"ops@oxagen.sh\""));
}
#[test]
fn a_policy_grantor_round_trips_distinctly_from_a_human() {
let receipt = ConsentReceipt::new(
"contextgraph-cloud",
&info(),
EgressScope::OrgTenant,
Grantor::Policy("data-egress-policy-v2".into()),
"2026-07-21T00:00:00Z",
);
let json = serde_json::to_string(&receipt).unwrap();
assert!(json.contains("\"kind\":\"policy\""));
let back: ConsentReceipt = serde_json::from_str(&json).unwrap();
assert_eq!(
back.grantor,
Grantor::Policy("data-egress-policy-v2".into())
);
assert_ne!(
back.grantor,
Grantor::Human("data-egress-policy-v2".into()),
"a policy grant must never be mistaken for a human's"
);
}
#[test]
fn an_open_ended_receipt_omits_expiry_and_is_always_live() {
let receipt = ConsentReceipt::new(
"contextgraph-cloud",
&info(),
EgressScope::ThirdPartyModel,
Grantor::Human("alice".into()),
"2026-07-21T00:00:00Z",
);
let json = serde_json::to_string(&receipt).unwrap();
assert!(
!json.contains("expires_at"),
"an absent expiry must be omitted, not serialized as null: {json}"
);
assert!(receipt.is_live("2099-01-01T00:00:00Z"));
}
#[test]
fn expiry_bounds_the_window_of_authorized_egress() {
let receipt = ConsentReceipt::new(
"contextgraph-cloud",
&info(),
EgressScope::ThirdPartyModel,
Grantor::Human("alice".into()),
"2026-07-21T00:00:00Z",
)
.with_expiry("2026-10-21T00:00:00Z");
assert!(receipt.is_live("2026-08-01T00:00:00Z"));
assert!(!receipt.is_live("2026-11-01T00:00:00Z"));
assert!(!receipt.is_live("2026-10-21T00:00:00Z"));
}
}