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}