1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct ConsentRecord {
32 pub provider_id: String,
34 pub data_flow: DataFlow,
36 pub granted_scope: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub granted_at: Option<String>,
41}
42
43impl ConsentRecord {
44 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 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
81fn 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#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum ConsentDecision {
100 Permitted,
104 NeedsConsent,
107 NeedsReceipts(Vec<EgressScope>),
111}
112
113#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
118pub struct ConsentStore {
119 #[serde(default)]
120 records: HashMap<String, ConsentRecord>,
121 #[serde(default)]
124 receipts: Vec<ConsentReceipt>,
125}
126
127impl ConsentStore {
128 pub fn new() -> Self {
129 Self::default()
130 }
131
132 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 pub fn record_receipt(&mut self, receipt: ConsentReceipt) {
151 self.receipts.push(receipt);
152 }
153
154 pub fn receipts(&self) -> &[ConsentReceipt] {
157 &self.receipts
158 }
159
160 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 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 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 pub fn revoke(&mut self, provider_id: &str) -> Option<ConsentRecord> {
195 self.records.remove(provider_id)
196 }
197
198 pub fn get(&self, provider_id: &str) -> Option<&ConsentRecord> {
200 self.records.get(provider_id)
201 }
202
203 pub fn is_consented(&self, provider_id: &str) -> bool {
205 self.records.contains_key(provider_id)
206 }
207
208 pub fn requires_consent(info: &ProviderInfo) -> bool {
213 info.data_flow.egress || info.data_flow.off_machine_scopes().next().is_some()
214 }
215
216 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 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 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 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 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 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 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 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 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 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 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 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 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}