use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use contextgraph_types::{
ConsentReceipt, DataFlow, EgressScope, ProviderInfo, format_protocol_timestamp,
is_protocol_timestamp,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConsentRecord {
pub provider_id: String,
pub data_flow: DataFlow,
pub granted_scope: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub granted_at: Option<String>,
}
impl ConsentRecord {
pub fn new(
provider_id: impl Into<String>,
data_flow: DataFlow,
granted_scope: impl Into<String>,
) -> Self {
Self {
provider_id: provider_id.into(),
data_flow,
granted_scope: granted_scope.into(),
granted_at: None,
}
}
pub fn granted_at(mut self, when: impl Into<String>) -> Self {
let when = when.into();
if is_protocol_timestamp(&when) {
self.granted_at = Some(when);
}
self
}
}
fn now_protocol_timestamp() -> String {
let now = SystemTime::now();
let seconds = match now.duration_since(UNIX_EPOCH) {
Ok(elapsed) => elapsed.as_secs() as i64,
Err(before_epoch) => -(before_epoch.duration().as_secs() as i64),
};
format_protocol_timestamp(seconds)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConsentDecision {
Permitted,
NeedsConsent,
NeedsReceipts(Vec<EgressScope>),
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ConsentStore {
#[serde(default)]
records: HashMap<String, ConsentRecord>,
#[serde(default)]
receipts: Vec<ConsentReceipt>,
}
impl ConsentStore {
pub fn new() -> Self {
Self::default()
}
pub fn record(&mut self, mut record: ConsentRecord) {
if record.granted_at.is_none() {
record.granted_at = Some(now_protocol_timestamp());
}
self.records.insert(record.provider_id.clone(), record);
}
pub fn record_receipt(&mut self, receipt: ConsentReceipt) {
self.receipts.push(receipt);
}
pub fn receipts(&self) -> &[ConsentReceipt] {
&self.receipts
}
pub fn receipts_for<'a>(
&'a self,
provider_id: &'a str,
) -> impl Iterator<Item = &'a ConsentReceipt> {
self.receipts
.iter()
.filter(move |receipt| receipt.provider_id == provider_id)
}
pub fn has_receipt(&self, provider_id: &str, scope: &EgressScope) -> bool {
self.receipts
.iter()
.any(|receipt| receipt.provider_id == provider_id && &receipt.scope == scope)
}
pub fn live_receipt(
&self,
provider_id: &str,
scope: &EgressScope,
now: &str,
) -> Option<&ConsentReceipt> {
self.receipts.iter().find(|receipt| {
receipt.provider_id == provider_id && &receipt.scope == scope && receipt.is_live(now)
})
}
pub fn revoke(&mut self, provider_id: &str) -> Option<ConsentRecord> {
self.records.remove(provider_id)
}
pub fn get(&self, provider_id: &str) -> Option<&ConsentRecord> {
self.records.get(provider_id)
}
pub fn is_consented(&self, provider_id: &str) -> bool {
self.records.contains_key(provider_id)
}
pub fn requires_consent(info: &ProviderInfo) -> bool {
info.data_flow.egress || info.data_flow.off_machine_scopes().next().is_some()
}
pub fn evaluate(&self, id: &str, info: &ProviderInfo) -> ConsentDecision {
let off_machine: Vec<&EgressScope> = info.data_flow.off_machine_scopes().collect();
if !off_machine.is_empty() {
let missing: Vec<EgressScope> = off_machine
.into_iter()
.filter(|scope| !self.has_receipt(id, scope))
.cloned()
.collect();
if missing.is_empty() {
ConsentDecision::Permitted
} else {
ConsentDecision::NeedsReceipts(missing)
}
} else if info.data_flow.egress {
if self.is_consented(id) {
ConsentDecision::Permitted
} else {
ConsentDecision::NeedsConsent
}
} else {
ConsentDecision::Permitted
}
}
pub fn permits(&self, id: &str, info: &ProviderInfo) -> bool {
matches!(self.evaluate(id, info), ConsentDecision::Permitted)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recording_consent_stamps_when_it_was_granted() {
let mut store = ConsentStore::new();
store.record(ConsentRecord::new(
"github",
DataFlow {
reads: true,
egress: true,
..DataFlow::default()
},
"issue titles and bodies",
));
let stamped = store
.records
.get("github")
.expect("the record is in the ledger");
let granted_at = stamped
.granted_at
.as_deref()
.expect("an audit ledger records when consent was granted");
assert!(
is_protocol_timestamp(granted_at),
"granted_at `{granted_at}` must be in the F4 temporal profile"
);
}
#[test]
fn a_caller_supplied_instant_is_preserved_not_overwritten() {
let mut store = ConsentStore::new();
store.record(
ConsentRecord::new("github", DataFlow::default(), "issue titles")
.granted_at("2026-01-01T00:00:00Z"),
);
assert_eq!(
store.records["github"].granted_at.as_deref(),
Some("2026-01-01T00:00:00Z")
);
}
#[test]
fn a_non_f4_instant_is_refused_rather_than_stored() {
let record = ConsentRecord::new("github", DataFlow::default(), "issue titles")
.granted_at("last tuesday");
assert_eq!(record.granted_at, None);
let record = ConsentRecord::new("github", DataFlow::default(), "issue titles")
.granted_at("2026-01-01T00:00:00+02:00");
assert_eq!(record.granted_at, None, "F4 is UTC-only");
}
fn egress_info() -> ProviderInfo {
ProviderInfo {
name: "contextgraph-github".into(),
version: "0.1.0".into(),
data_flow: DataFlow {
reads: true,
writes: false,
egress: true,
egress_scopes: vec![],
},
}
}
fn scoped_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],
},
}
}
fn local_info() -> ProviderInfo {
ProviderInfo {
name: "contextgraph-docs".into(),
version: "0.1.0".into(),
data_flow: DataFlow {
reads: true,
writes: false,
egress: false,
egress_scopes: vec![],
},
}
}
#[test]
fn local_providers_never_need_consent() {
let store = ConsentStore::new();
let info = local_info();
assert!(!ConsentStore::requires_consent(&info));
assert!(store.permits("contextgraph-docs", &info));
}
#[test]
fn egress_providers_are_gated_until_consent_is_recorded() {
let mut store = ConsentStore::new();
let info = egress_info();
assert!(ConsentStore::requires_consent(&info));
assert!(!store.permits("contextgraph-github", &info));
store.record(ConsentRecord::new(
"contextgraph-github",
info.data_flow.clone(),
"open issue titles + bodies leave to github.com",
));
assert!(store.permits("contextgraph-github", &info));
assert_eq!(
store
.get("contextgraph-github")
.map(|r| r.granted_scope.as_str()),
Some("open issue titles + bodies leave to github.com")
);
}
#[test]
fn revoking_consent_reshuts_the_gate() {
let mut store = ConsentStore::new();
let info = egress_info();
store.record(ConsentRecord::new(
"contextgraph-github",
info.data_flow.clone(),
"issues",
));
assert!(store.permits("contextgraph-github", &info));
let revoked = store
.revoke("contextgraph-github")
.expect("a record existed");
assert_eq!(revoked.provider_id, "contextgraph-github");
assert!(!store.permits("contextgraph-github", &info));
}
#[test]
fn consent_store_is_serde_able_for_persistence() {
let mut store = ConsentStore::new();
store.record(ConsentRecord::new(
"contextgraph-github",
DataFlow {
reads: true,
writes: false,
egress: true,
egress_scopes: vec![],
},
"issues + PRs",
));
let json = serde_json::to_string(&store).unwrap();
let back: ConsentStore = serde_json::from_str(&json).unwrap();
assert_eq!(back, store);
assert!(back.is_consented("contextgraph-github"));
}
use contextgraph_types::Grantor;
fn receipt(provider: &str, scope: EgressScope) -> ConsentReceipt {
ConsentReceipt::new(
provider,
&scoped_info(),
scope,
Grantor::Human("alice".into()),
"2026-07-21T00:00:00Z",
)
}
#[test]
fn a_scoped_provider_is_gated_until_every_off_machine_scope_has_a_receipt() {
let mut store = ConsentStore::new();
let info = scoped_info();
assert!(ConsentStore::requires_consent(&info));
match store.evaluate("contextgraph-cloud", &info) {
ConsentDecision::NeedsReceipts(missing) => {
assert_eq!(missing, vec![EgressScope::ThirdPartyModel]);
}
other => panic!("expected NeedsReceipts, got {other:?}"),
}
assert!(!store.permits("contextgraph-cloud", &info));
store.record(ConsentRecord::new(
"contextgraph-cloud",
info.data_flow.clone(),
"legacy boolean consent",
));
assert!(!store.permits("contextgraph-cloud", &info));
store.record_receipt(receipt("contextgraph-cloud", EgressScope::ThirdPartyModel));
assert_eq!(
store.evaluate("contextgraph-cloud", &info),
ConsentDecision::Permitted
);
assert!(store.permits("contextgraph-cloud", &info));
}
#[test]
fn a_receipt_for_the_wrong_scope_does_not_unlock_a_different_scope() {
let mut store = ConsentStore::new();
let info = ProviderInfo {
name: "contextgraph-cloud".into(),
version: "0.1.0".into(),
data_flow: DataFlow {
reads: true,
writes: false,
egress: true,
egress_scopes: vec![EgressScope::ThirdPartyIndex, EgressScope::ThirdPartyModel],
},
};
store.record_receipt(receipt("contextgraph-cloud", EgressScope::ThirdPartyIndex));
match store.evaluate("contextgraph-cloud", &info) {
ConsentDecision::NeedsReceipts(missing) => {
assert_eq!(missing, vec![EgressScope::ThirdPartyModel]);
}
other => panic!("expected NeedsReceipts for the model scope, got {other:?}"),
}
}
#[test]
fn a_local_only_scope_needs_no_receipt() {
let store = ConsentStore::new();
let info = ProviderInfo {
name: "contextgraph-docs".into(),
version: "0.1.0".into(),
data_flow: DataFlow {
reads: true,
writes: false,
egress: false,
egress_scopes: vec![EgressScope::LocalOnly],
},
};
assert!(!ConsentStore::requires_consent(&info));
assert!(store.permits("contextgraph-docs", &info));
}
#[test]
fn receipts_are_append_only_and_carry_the_full_audit_trail() {
let mut store = ConsentStore::new();
store.record_receipt(receipt("contextgraph-cloud", EgressScope::ThirdPartyModel));
store.record_receipt(
ConsentReceipt::new(
"contextgraph-cloud",
&scoped_info(),
EgressScope::ThirdPartyIndex,
Grantor::Policy("data-egress-policy-v2".into()),
"2026-07-22T00:00:00Z",
)
.with_expiry("2026-08-22T00:00:00Z"),
);
assert_eq!(store.receipts().len(), 2);
assert_eq!(store.receipts_for("contextgraph-cloud").count(), 2);
assert_eq!(store.receipts()[0].scope, EgressScope::ThirdPartyModel);
assert!(matches!(store.receipts()[1].grantor, Grantor::Policy(_)));
}
#[test]
fn an_expired_receipt_is_not_live_but_stays_in_the_ledger() {
let mut store = ConsentStore::new();
store.record_receipt(
receipt("contextgraph-cloud", EgressScope::ThirdPartyModel)
.with_expiry("2026-07-22T00:00:00Z"),
);
assert!(
store
.live_receipt(
"contextgraph-cloud",
&EgressScope::ThirdPartyModel,
"2026-07-21T12:00:00Z",
)
.is_some()
);
assert!(
store
.live_receipt(
"contextgraph-cloud",
&EgressScope::ThirdPartyModel,
"2026-07-23T00:00:00Z",
)
.is_none()
);
assert_eq!(
store.receipts().len(),
1,
"expiry never prunes the audit trail"
);
}
#[test]
fn a_serialized_store_carries_its_receipt_ledger_across_runs() {
let mut store = ConsentStore::new();
store.record_receipt(receipt("contextgraph-cloud", EgressScope::ThirdPartyModel));
let back: ConsentStore = serde_json::from_str(&serde_json::to_string(&store).unwrap())
.expect("a store with receipts round-trips");
assert_eq!(back, store);
assert!(back.has_receipt("contextgraph-cloud", &EgressScope::ThirdPartyModel));
}
}