Skip to main content

contextgraph_host/
consent.rs

1//! Consent gating for egress providers (`SPEC.md` §4 and §10;
2//! `SPEC.md` §7 point 4).
3//!
4//! The security-critical rule: a provider that declares `egress` — anything
5//! that could send workspace content off the local machine — MUST NOT be
6//! queried until the user has recorded explicit, one-time consent that
7//! **names what leaves**. A host never auto-enables egress. Read/write-only
8//! providers carry no such gate. The store is in-memory and serde-able so a
9//! host can persist the user's decisions across runs (task deliverable 4).
10//!
11//! Scope-level consent is recorded as a
12//! [`ConsentReceipt`](contextgraph_types::ConsentReceipt) — a protocol-defined
13//! shape that lives in `contextgraph-types` alongside the usage report, since
14//! any host claiming the consent guarantee must produce it and any auditor must
15//! be able to read it. This module holds the host machinery that *consumes*
16//! receipts: the append-only ledger and the gate.
17
18use std::collections::HashMap;
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use contextgraph_types::{
22    ConsentReceipt, DataFlow, EgressScope, ProviderInfo, format_protocol_timestamp,
23    is_protocol_timestamp,
24};
25use serde::{Deserialize, Serialize};
26
27/// A recorded consent decision for one provider. `granted_scope` is the
28/// human-readable description of what data flows out, shown to the user at
29/// consent time and retained as the audit of what they agreed to (SPEC.md §4).
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct ConsentRecord {
32    /// The provider id this consent applies to (the host's routing key).
33    pub provider_id: String,
34    /// The data-flow direction the user consented to — names what leaves.
35    pub data_flow: DataFlow,
36    /// Human-readable scope: what content is permitted to leave the machine.
37    pub granted_scope: String,
38    /// When consent was granted (RFC 3339), if the host records it.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub granted_at: Option<String>,
41}
42
43impl ConsentRecord {
44    /// Record consent for a provider, naming the data-flow direction and the
45    /// scope of what may leave.
46    ///
47    /// `granted_at` is left unset here and stamped by
48    /// [`ConsentStore::record`] at the moment the decision enters the ledger —
49    /// the only place that can honestly claim to know *when* consent was
50    /// given. Use [`granted_at`](Self::granted_at) to supply your own instant
51    /// (replaying a persisted decision, or a host with its own clock).
52    pub fn new(
53        provider_id: impl Into<String>,
54        data_flow: DataFlow,
55        granted_scope: impl Into<String>,
56    ) -> Self {
57        Self {
58            provider_id: provider_id.into(),
59            data_flow,
60            granted_scope: granted_scope.into(),
61            granted_at: None,
62        }
63    }
64
65    /// Pin when this consent was granted, as a protocol timestamp
66    /// (`SPEC.md` §6.1 F4).
67    ///
68    /// A non-F4 instant is **rejected rather than stored**: the ledger is the
69    /// audit trail, and a timestamp nobody can parse is worse than the absence
70    /// the field already models honestly. Same guard the temporal fields carry
71    /// on the wire — never emit a string outside the profile.
72    pub fn granted_at(mut self, when: impl Into<String>) -> Self {
73        let when = when.into();
74        if is_protocol_timestamp(&when) {
75            self.granted_at = Some(when);
76        }
77        self
78    }
79}
80
81/// The current instant as a protocol timestamp (`SPEC.md` §6.1 F4).
82///
83/// A system clock set before 1970 yields a negative Unix time, which
84/// [`format_protocol_timestamp`] handles rather than saturating — a wrong-but-
85/// well-formed timestamp is still auditable, where a clamped one silently
86/// claims the epoch.
87fn now_protocol_timestamp() -> String {
88    let now = SystemTime::now();
89    let seconds = match now.duration_since(UNIX_EPOCH) {
90        Ok(elapsed) => elapsed.as_secs() as i64,
91        Err(before_epoch) => -(before_epoch.duration().as_secs() as i64),
92    };
93    format_protocol_timestamp(seconds)
94}
95
96/// The host's pre-query consent verdict for one provider — the gate result the
97/// host acts on before transmitting a query (`docs/context-reuse.md` §3).
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum ConsentDecision {
100    /// The query may be transmitted: the provider is local, or every off-machine
101    /// egress scope it declares has a recorded receipt, or its legacy boolean
102    /// egress consent is on file.
103    Permitted,
104    /// The provider declares `egress` with **no** egress scopes (the pre-scope
105    /// boolean contract) and no consent is recorded. Transmitting is refused.
106    NeedsConsent,
107    /// The provider declares off-machine egress scope(s) with **no** recorded
108    /// consent receipt. Carries exactly the scopes still lacking a receipt, so
109    /// the host's typed error names what would leave unconsented.
110    NeedsReceipts(Vec<EgressScope>),
111}
112
113/// The set of consent decisions a host holds: a keyed table of legacy boolean
114/// [`ConsentRecord`]s and an **append-only** ledger of scope-level
115/// [`ConsentReceipt`]s (`docs/context-reuse.md` §3). Both are serde-able so a
116/// host can persist a user's decisions — and the receipt ledger — across runs.
117#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
118pub struct ConsentStore {
119    #[serde(default)]
120    records: HashMap<String, ConsentRecord>,
121    /// Append-only: receipts are pushed, never removed or mutated, so the full
122    /// history of what was agreed survives as the audit trail.
123    #[serde(default)]
124    receipts: Vec<ConsentReceipt>,
125}
126
127impl ConsentStore {
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    /// Record (or replace) legacy boolean consent for a provider.
133    ///
134    /// Stamps `granted_at` with the host clock when the caller left it unset.
135    /// The field existed from the start and was *never* populated by anything
136    /// — every consent decision the reference host recorded went into the
137    /// ledger with no time on it, which is precisely the datum an audit needs
138    /// ("when did I agree to this?"). Stamping at insertion is the one place
139    /// that can answer honestly; a caller replaying a persisted decision keeps
140    /// its original instant via [`ConsentRecord::granted_at`].
141    pub fn record(&mut self, mut record: ConsentRecord) {
142        if record.granted_at.is_none() {
143            record.granted_at = Some(now_protocol_timestamp());
144        }
145        self.records.insert(record.provider_id.clone(), record);
146    }
147
148    /// Append a scope-level consent receipt to the audit ledger. Append-only:
149    /// this never removes or edits an earlier receipt (§3).
150    pub fn record_receipt(&mut self, receipt: ConsentReceipt) {
151        self.receipts.push(receipt);
152    }
153
154    /// The full append-only receipt ledger, in the order receipts were granted
155    /// — the audit trail.
156    pub fn receipts(&self) -> &[ConsentReceipt] {
157        &self.receipts
158    }
159
160    /// Every receipt recorded for a provider, in grant order.
161    pub fn receipts_for<'a>(
162        &'a self,
163        provider_id: &'a str,
164    ) -> impl Iterator<Item = &'a ConsentReceipt> {
165        self.receipts
166            .iter()
167            .filter(move |receipt| receipt.provider_id == provider_id)
168    }
169
170    /// Whether any recorded receipt authorizes `scope` for `provider_id`
171    /// (**presence**, ignoring expiry). This is what the zero-clock runtime gate
172    /// consults; a host that also enforces expiry uses [`live_receipt`](Self::live_receipt).
173    pub fn has_receipt(&self, provider_id: &str, scope: &EgressScope) -> bool {
174        self.receipts
175            .iter()
176            .any(|receipt| receipt.provider_id == provider_id && &receipt.scope == scope)
177    }
178
179    /// The receipt authorizing `scope` for `provider_id` that is live at `now`
180    /// (presence **and** non-expiry), if any. A host enforcing expiry gates on
181    /// this against its own clock (`docs/context-reuse.md` §3).
182    pub fn live_receipt(
183        &self,
184        provider_id: &str,
185        scope: &EgressScope,
186        now: &str,
187    ) -> Option<&ConsentReceipt> {
188        self.receipts.iter().find(|receipt| {
189            receipt.provider_id == provider_id && &receipt.scope == scope && receipt.is_live(now)
190        })
191    }
192
193    /// Withdraw consent for a provider, returning the prior record if any.
194    pub fn revoke(&mut self, provider_id: &str) -> Option<ConsentRecord> {
195        self.records.remove(provider_id)
196    }
197
198    /// The recorded decision for a provider, if consent was granted.
199    pub fn get(&self, provider_id: &str) -> Option<&ConsentRecord> {
200        self.records.get(provider_id)
201    }
202
203    /// Whether consent has been recorded for a provider.
204    pub fn is_consented(&self, provider_id: &str) -> bool {
205        self.records.contains_key(provider_id)
206    }
207
208    /// Whether a provider needs consent before any query: a provider needs it
209    /// if it declares the boolean `egress` flag **or** any off-machine egress
210    /// scope (§3.5, §3). A purely local provider is always permitted — nothing
211    /// it can do leaves the machine.
212    pub fn requires_consent(info: &ProviderInfo) -> bool {
213        info.data_flow.egress || info.data_flow.off_machine_scopes().next().is_some()
214    }
215
216    /// The host's pre-query consent gate: may we transmit a query to this
217    /// provider right now, and if not, *why* (`docs/context-reuse.md` §3)?
218    ///
219    /// - A provider declaring **off-machine egress scopes** is governed by the
220    ///   receipt gate: permitted only when every off-machine scope has a
221    ///   recorded receipt; otherwise [`NeedsReceipts`](ConsentDecision::NeedsReceipts)
222    ///   names the scopes still missing one. (This is presence-based — a host
223    ///   enforcing expiry prunes/consults live receipts with its own clock.)
224    /// - A provider declaring only the **boolean `egress`** flag (no scopes) is
225    ///   governed by the legacy gate: permitted with a recorded [`ConsentRecord`],
226    ///   else [`NeedsConsent`](ConsentDecision::NeedsConsent).
227    /// - A purely local provider is [`Permitted`](ConsentDecision::Permitted).
228    pub fn evaluate(&self, id: &str, info: &ProviderInfo) -> ConsentDecision {
229        let off_machine: Vec<&EgressScope> = info.data_flow.off_machine_scopes().collect();
230        if !off_machine.is_empty() {
231            let missing: Vec<EgressScope> = off_machine
232                .into_iter()
233                .filter(|scope| !self.has_receipt(id, scope))
234                .cloned()
235                .collect();
236            if missing.is_empty() {
237                ConsentDecision::Permitted
238            } else {
239                ConsentDecision::NeedsReceipts(missing)
240            }
241        } else if info.data_flow.egress {
242            if self.is_consented(id) {
243                ConsentDecision::Permitted
244            } else {
245                ConsentDecision::NeedsConsent
246            }
247        } else {
248            ConsentDecision::Permitted
249        }
250    }
251
252    /// The boolean form of [`evaluate`](Self::evaluate): may we send the payload
253    /// to this provider right now?
254    pub fn permits(&self, id: &str, info: &ProviderInfo) -> bool {
255        matches!(self.evaluate(id, info), ConsentDecision::Permitted)
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn recording_consent_stamps_when_it_was_granted() {
265        let mut store = ConsentStore::new();
266        store.record(ConsentRecord::new(
267            "github",
268            DataFlow {
269                reads: true,
270                egress: true,
271                ..DataFlow::default()
272            },
273            "issue titles and bodies",
274        ));
275
276        let stamped = store
277            .records
278            .get("github")
279            .expect("the record is in the ledger");
280        let granted_at = stamped
281            .granted_at
282            .as_deref()
283            .expect("an audit ledger records when consent was granted");
284        assert!(
285            is_protocol_timestamp(granted_at),
286            "granted_at `{granted_at}` must be in the F4 temporal profile"
287        );
288    }
289
290    #[test]
291    fn a_caller_supplied_instant_is_preserved_not_overwritten() {
292        // Replaying a persisted decision must keep its original instant —
293        // re-stamping on load would quietly rewrite the audit trail to "now".
294        let mut store = ConsentStore::new();
295        store.record(
296            ConsentRecord::new("github", DataFlow::default(), "issue titles")
297                .granted_at("2026-01-01T00:00:00Z"),
298        );
299
300        assert_eq!(
301            store.records["github"].granted_at.as_deref(),
302            Some("2026-01-01T00:00:00Z")
303        );
304    }
305
306    #[test]
307    fn a_non_f4_instant_is_refused_rather_than_stored() {
308        // The ledger never holds a timestamp nobody can parse; the field falls
309        // back to the honest absence it already models.
310        let record = ConsentRecord::new("github", DataFlow::default(), "issue titles")
311            .granted_at("last tuesday");
312        assert_eq!(record.granted_at, None);
313
314        let record = ConsentRecord::new("github", DataFlow::default(), "issue titles")
315            .granted_at("2026-01-01T00:00:00+02:00");
316        assert_eq!(record.granted_at, None, "F4 is UTC-only");
317    }
318
319    fn egress_info() -> ProviderInfo {
320        ProviderInfo {
321            name: "contextgraph-github".into(),
322            version: "0.1.0".into(),
323            data_flow: DataFlow {
324                reads: true,
325                writes: false,
326                egress: true,
327                egress_scopes: vec![],
328            },
329        }
330    }
331
332    fn scoped_info() -> ProviderInfo {
333        ProviderInfo {
334            name: "contextgraph-cloud".into(),
335            version: "0.1.0".into(),
336            data_flow: DataFlow {
337                reads: true,
338                writes: false,
339                egress: true,
340                egress_scopes: vec![EgressScope::ThirdPartyModel],
341            },
342        }
343    }
344
345    fn local_info() -> ProviderInfo {
346        ProviderInfo {
347            name: "contextgraph-docs".into(),
348            version: "0.1.0".into(),
349            data_flow: DataFlow {
350                reads: true,
351                writes: false,
352                egress: false,
353                egress_scopes: vec![],
354            },
355        }
356    }
357
358    #[test]
359    fn local_providers_never_need_consent() {
360        let store = ConsentStore::new();
361        let info = local_info();
362        assert!(!ConsentStore::requires_consent(&info));
363        assert!(store.permits("contextgraph-docs", &info));
364    }
365
366    #[test]
367    fn egress_providers_are_gated_until_consent_is_recorded() {
368        let mut store = ConsentStore::new();
369        let info = egress_info();
370        assert!(ConsentStore::requires_consent(&info));
371        // No consent yet → the gate is shut.
372        assert!(!store.permits("contextgraph-github", &info));
373
374        store.record(ConsentRecord::new(
375            "contextgraph-github",
376            info.data_flow.clone(),
377            "open issue titles + bodies leave to github.com",
378        ));
379        assert!(store.permits("contextgraph-github", &info));
380        assert_eq!(
381            store
382                .get("contextgraph-github")
383                .map(|r| r.granted_scope.as_str()),
384            Some("open issue titles + bodies leave to github.com")
385        );
386    }
387
388    #[test]
389    fn revoking_consent_reshuts_the_gate() {
390        let mut store = ConsentStore::new();
391        let info = egress_info();
392        store.record(ConsentRecord::new(
393            "contextgraph-github",
394            info.data_flow.clone(),
395            "issues",
396        ));
397        assert!(store.permits("contextgraph-github", &info));
398        let revoked = store
399            .revoke("contextgraph-github")
400            .expect("a record existed");
401        assert_eq!(revoked.provider_id, "contextgraph-github");
402        assert!(!store.permits("contextgraph-github", &info));
403    }
404
405    #[test]
406    fn consent_store_is_serde_able_for_persistence() {
407        let mut store = ConsentStore::new();
408        store.record(ConsentRecord::new(
409            "contextgraph-github",
410            DataFlow {
411                reads: true,
412                writes: false,
413                egress: true,
414                egress_scopes: vec![],
415            },
416            "issues + PRs",
417        ));
418        let json = serde_json::to_string(&store).unwrap();
419        let back: ConsentStore = serde_json::from_str(&json).unwrap();
420        assert_eq!(back, store);
421        assert!(back.is_consented("contextgraph-github"));
422    }
423
424    use contextgraph_types::Grantor;
425
426    fn receipt(provider: &str, scope: EgressScope) -> ConsentReceipt {
427        ConsentReceipt::new(
428            provider,
429            &scoped_info(),
430            scope,
431            Grantor::Human("alice".into()),
432            "2026-07-21T00:00:00Z",
433        )
434    }
435
436    #[test]
437    fn a_scoped_provider_is_gated_until_every_off_machine_scope_has_a_receipt() {
438        let mut store = ConsentStore::new();
439        let info = scoped_info();
440        assert!(ConsentStore::requires_consent(&info));
441
442        // No receipt yet → the gate names the missing scope, and the query is
443        // refused with the scope-specific decision (not the legacy boolean).
444        match store.evaluate("contextgraph-cloud", &info) {
445            ConsentDecision::NeedsReceipts(missing) => {
446                assert_eq!(missing, vec![EgressScope::ThirdPartyModel]);
447            }
448            other => panic!("expected NeedsReceipts, got {other:?}"),
449        }
450        assert!(!store.permits("contextgraph-cloud", &info));
451
452        // A boolean ConsentRecord does NOT satisfy a scope gate — only a
453        // receipt for the declared scope does.
454        store.record(ConsentRecord::new(
455            "contextgraph-cloud",
456            info.data_flow.clone(),
457            "legacy boolean consent",
458        ));
459        assert!(!store.permits("contextgraph-cloud", &info));
460
461        // Record the receipt for the declared scope → permitted.
462        store.record_receipt(receipt("contextgraph-cloud", EgressScope::ThirdPartyModel));
463        assert_eq!(
464            store.evaluate("contextgraph-cloud", &info),
465            ConsentDecision::Permitted
466        );
467        assert!(store.permits("contextgraph-cloud", &info));
468    }
469
470    #[test]
471    fn a_receipt_for_the_wrong_scope_does_not_unlock_a_different_scope() {
472        let mut store = ConsentStore::new();
473        let info = ProviderInfo {
474            name: "contextgraph-cloud".into(),
475            version: "0.1.0".into(),
476            data_flow: DataFlow {
477                reads: true,
478                writes: false,
479                egress: true,
480                egress_scopes: vec![EgressScope::ThirdPartyIndex, EgressScope::ThirdPartyModel],
481            },
482        };
483        // Only the index scope is consented; the model scope is still missing.
484        store.record_receipt(receipt("contextgraph-cloud", EgressScope::ThirdPartyIndex));
485        match store.evaluate("contextgraph-cloud", &info) {
486            ConsentDecision::NeedsReceipts(missing) => {
487                assert_eq!(missing, vec![EgressScope::ThirdPartyModel]);
488            }
489            other => panic!("expected NeedsReceipts for the model scope, got {other:?}"),
490        }
491    }
492
493    #[test]
494    fn a_local_only_scope_needs_no_receipt() {
495        let store = ConsentStore::new();
496        let info = ProviderInfo {
497            name: "contextgraph-docs".into(),
498            version: "0.1.0".into(),
499            data_flow: DataFlow {
500                reads: true,
501                writes: false,
502                egress: false,
503                egress_scopes: vec![EgressScope::LocalOnly],
504            },
505        };
506        // local-only is on-machine, so it triggers no receipt gate at all.
507        assert!(!ConsentStore::requires_consent(&info));
508        assert!(store.permits("contextgraph-docs", &info));
509    }
510
511    #[test]
512    fn receipts_are_append_only_and_carry_the_full_audit_trail() {
513        let mut store = ConsentStore::new();
514        store.record_receipt(receipt("contextgraph-cloud", EgressScope::ThirdPartyModel));
515        store.record_receipt(
516            ConsentReceipt::new(
517                "contextgraph-cloud",
518                &scoped_info(),
519                EgressScope::ThirdPartyIndex,
520                Grantor::Policy("data-egress-policy-v2".into()),
521                "2026-07-22T00:00:00Z",
522            )
523            .with_expiry("2026-08-22T00:00:00Z"),
524        );
525        // Both receipts retained (append-only), in grant order.
526        assert_eq!(store.receipts().len(), 2);
527        assert_eq!(store.receipts_for("contextgraph-cloud").count(), 2);
528        assert_eq!(store.receipts()[0].scope, EgressScope::ThirdPartyModel);
529        assert!(matches!(store.receipts()[1].grantor, Grantor::Policy(_)));
530    }
531
532    #[test]
533    fn an_expired_receipt_is_not_live_but_stays_in_the_ledger() {
534        let mut store = ConsentStore::new();
535        store.record_receipt(
536            receipt("contextgraph-cloud", EgressScope::ThirdPartyModel)
537                .with_expiry("2026-07-22T00:00:00Z"),
538        );
539        // Live before expiry, not after — but the receipt is never removed.
540        assert!(
541            store
542                .live_receipt(
543                    "contextgraph-cloud",
544                    &EgressScope::ThirdPartyModel,
545                    "2026-07-21T12:00:00Z",
546                )
547                .is_some()
548        );
549        assert!(
550            store
551                .live_receipt(
552                    "contextgraph-cloud",
553                    &EgressScope::ThirdPartyModel,
554                    "2026-07-23T00:00:00Z",
555                )
556                .is_none()
557        );
558        assert_eq!(
559            store.receipts().len(),
560            1,
561            "expiry never prunes the audit trail"
562        );
563    }
564
565    #[test]
566    fn a_serialized_store_carries_its_receipt_ledger_across_runs() {
567        // The ledger is the durable audit artifact, so it must survive the
568        // round-trip a host does when persisting decisions between sessions.
569        let mut store = ConsentStore::new();
570        store.record_receipt(receipt("contextgraph-cloud", EgressScope::ThirdPartyModel));
571        let back: ConsentStore = serde_json::from_str(&serde_json::to_string(&store).unwrap())
572            .expect("a store with receipts round-trips");
573        assert_eq!(back, store);
574        assert!(back.has_receipt("contextgraph-cloud", &EgressScope::ThirdPartyModel));
575    }
576}