Skip to main content

contextgraph_types/
consent.rs

1//! Consent receipts — the durable, audit-grade record of a granted egress
2//! permission (`docs/context-reuse.md` §3).
3//!
4//! A boolean consent flag answers "is this provider allowed?" *in the moment it
5//! is asked*, inside a process that will exit. It cannot answer the question an
6//! auditor actually asks months later: "**what** left the machine, **to whom**,
7//! **who** agreed, and **when**?" A [`ConsentReceipt`] is that answer — consent
8//! as an *artifact* rather than an event.
9//!
10//! Like [`UsageReport`](crate::UsageReport), a receipt is a **host-side
11//! artifact, not a wire message**: it rides no envelope variant, and a provider
12//! implements nothing to make one possible. It lives here rather than in the
13//! host crate for the same reason the usage report does — it is a *protocol-
14//! defined shape*. Any host, in any language, that claims to implement the
15//! consent guarantee must produce this shape, and any auditor reading a ledger
16//! must be able to parse it without depending on one particular host
17//! implementation. The gate that *consumes* receipts
18//! ([`ConsentStore`](https://docs.rs/contextgraph-host/latest/contextgraph_host/consent/struct.ConsentStore.html))
19//! is host machinery and stays in the host crate.
20//!
21//! The receipt pins the provider's identity at grant time, names an accountable
22//! [`Grantor`], and carries an optional expiry. Hosts hold receipts in an
23//! **append-only** ledger: a new grant never edits or erases an old one, so the
24//! history of consent is itself the audit trail.
25
26use serde::{Deserialize, Serialize};
27
28use crate::capability::ProviderInfo;
29use crate::scope::EgressScope;
30
31/// Who granted a consent receipt (`docs/context-reuse.md` §3). Recorded so the
32/// audit trail names an accountable party, not just a moment.
33///
34/// Serializes as a tagged object — `{"kind": "human", "id": "…"}` — so a
35/// grantor is self-describing in a persisted ledger rather than a bare string
36/// whose meaning depends on out-of-band convention.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(tag = "kind", content = "id", rename_all = "snake_case")]
39pub enum Grantor {
40    /// A human user, identified however the host names them at consent time
41    /// (a user id, email, or display name).
42    Human(String),
43    /// An automated policy, identified by its policy id or name — consent
44    /// granted by a rule rather than a person present in the moment.
45    Policy(String),
46}
47
48/// An audit-grade record that consent was granted for one provider to send
49/// content under one [egress scope](EgressScope) (`docs/context-reuse.md` §3).
50///
51/// It pins the provider's identity at grant time (so a later rename can't
52/// retroactively rewrite what was agreed), names the [`Grantor`], and carries
53/// an optional expiry.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct ConsentReceipt {
56    /// The provider id (host routing/consent key) this receipt authorizes.
57    pub provider_id: String,
58    /// The egress scope consented to. Content leaving this provider under this
59    /// scope is authorized; any other off-machine scope it declares is not,
60    /// until its own receipt exists.
61    pub scope: EgressScope,
62    /// The provider's declared name at grant time — pinned so the audit trail
63    /// survives the provider being renamed or swapped.
64    pub provider_name: String,
65    /// The provider's declared version at grant time.
66    pub provider_version: String,
67    /// Who granted consent (a human or a policy).
68    pub grantor: Grantor,
69    /// When consent was granted (RFC 3339), supplied by the host's clock.
70    pub granted_at: String,
71    /// When consent expires (RFC 3339), if it does. `None` ⇒ open-ended.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub expires_at: Option<String>,
74}
75
76impl ConsentReceipt {
77    /// Record consent for `provider` to egress under `scope`, granted by
78    /// `grantor` at `granted_at` (an RFC 3339 instant from the host's clock).
79    /// The provider's identity is copied out of `info` and pinned into the
80    /// receipt. Open-ended by default; add an expiry with
81    /// [`with_expiry`](Self::with_expiry).
82    pub fn new(
83        provider_id: impl Into<String>,
84        info: &ProviderInfo,
85        scope: EgressScope,
86        grantor: Grantor,
87        granted_at: impl Into<String>,
88    ) -> Self {
89        Self {
90            provider_id: provider_id.into(),
91            scope,
92            provider_name: info.name.clone(),
93            provider_version: info.version.clone(),
94            grantor,
95            granted_at: granted_at.into(),
96            expires_at: None,
97        }
98    }
99
100    /// Set the receipt's expiry (RFC 3339).
101    pub fn with_expiry(mut self, expires_at: impl Into<String>) -> Self {
102        self.expires_at = Some(expires_at.into());
103        self
104    }
105
106    /// Whether this receipt is still live at `now` (an RFC 3339 instant). A
107    /// receipt with no expiry is always live; otherwise it is live while
108    /// `now < expires_at`.
109    ///
110    /// The comparison is lexicographic on the RFC 3339 strings, which is
111    /// correct for fixed-width UTC (`Z`) timestamps — the form a host stamps —
112    /// so liveness needs no calendar parsing and the type stays dependency-free.
113    /// The runtime consent gate is presence-based (it does not carry a clock);
114    /// a host that enforces expiry consults this against its own `now`.
115    pub fn is_live(&self, now: &str) -> bool {
116        match &self.expires_at {
117            Some(expiry) => now < expiry.as_str(),
118            None => true,
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::capability::DataFlow;
127
128    fn info() -> ProviderInfo {
129        ProviderInfo {
130            name: "contextgraph-cloud".into(),
131            version: "0.1.0".into(),
132            data_flow: DataFlow {
133                reads: true,
134                writes: false,
135                egress: true,
136                egress_scopes: vec![EgressScope::ThirdPartyModel],
137            },
138        }
139    }
140
141    #[test]
142    fn a_receipt_pins_provider_identity_and_round_trips() {
143        let receipt = ConsentReceipt::new(
144            "contextgraph-cloud",
145            &info(),
146            EgressScope::ThirdPartyModel,
147            Grantor::Human("ops@oxagen.sh".into()),
148            "2026-07-21T00:00:00Z",
149        );
150        assert_eq!(receipt.provider_name, "contextgraph-cloud");
151        assert_eq!(receipt.provider_version, "0.1.0");
152
153        let json = serde_json::to_string(&receipt).unwrap();
154        let back: ConsentReceipt = serde_json::from_str(&json).unwrap();
155        assert_eq!(back, receipt);
156        // The grantor is a self-describing tagged object in a persisted ledger.
157        assert!(json.contains("\"kind\":\"human\""));
158        assert!(json.contains("\"id\":\"ops@oxagen.sh\""));
159    }
160
161    #[test]
162    fn a_policy_grantor_round_trips_distinctly_from_a_human() {
163        let receipt = ConsentReceipt::new(
164            "contextgraph-cloud",
165            &info(),
166            EgressScope::OrgTenant,
167            Grantor::Policy("data-egress-policy-v2".into()),
168            "2026-07-21T00:00:00Z",
169        );
170        let json = serde_json::to_string(&receipt).unwrap();
171        assert!(json.contains("\"kind\":\"policy\""));
172        let back: ConsentReceipt = serde_json::from_str(&json).unwrap();
173        assert_eq!(
174            back.grantor,
175            Grantor::Policy("data-egress-policy-v2".into())
176        );
177        assert_ne!(
178            back.grantor,
179            Grantor::Human("data-egress-policy-v2".into()),
180            "a policy grant must never be mistaken for a human's"
181        );
182    }
183
184    #[test]
185    fn an_open_ended_receipt_omits_expiry_and_is_always_live() {
186        let receipt = ConsentReceipt::new(
187            "contextgraph-cloud",
188            &info(),
189            EgressScope::ThirdPartyModel,
190            Grantor::Human("alice".into()),
191            "2026-07-21T00:00:00Z",
192        );
193        let json = serde_json::to_string(&receipt).unwrap();
194        assert!(
195            !json.contains("expires_at"),
196            "an absent expiry must be omitted, not serialized as null: {json}"
197        );
198        assert!(receipt.is_live("2099-01-01T00:00:00Z"));
199    }
200
201    #[test]
202    fn expiry_bounds_the_window_of_authorized_egress() {
203        let receipt = ConsentReceipt::new(
204            "contextgraph-cloud",
205            &info(),
206            EgressScope::ThirdPartyModel,
207            Grantor::Human("alice".into()),
208            "2026-07-21T00:00:00Z",
209        )
210        .with_expiry("2026-10-21T00:00:00Z");
211        assert!(receipt.is_live("2026-08-01T00:00:00Z"));
212        assert!(!receipt.is_live("2026-11-01T00:00:00Z"));
213        // The boundary instant is not live: liveness is `now < expires_at`.
214        assert!(!receipt.is_live("2026-10-21T00:00:00Z"));
215    }
216}