Skip to main content

contextgraph_types/
scope.rs

1//! Egress-scope vocabulary — a closed, extensible classification of *where*
2//! a provider's content goes (`docs/context-reuse.md` §3).
3//!
4//! `DataFlow.egress` answers a yes/no question: does anything leave the machine?
5//! That boolean is enough to gate consent, but not enough to *record* it: an
6//! auditor asking "what left, and to whom?" months later needs the class of
7//! destination, not just the fact of departure. [`EgressScope`] is that class.
8//!
9//! Four **normative base scopes** form the closed core every host and provider
10//! agree on:
11//!
12//! - [`LocalOnly`](EgressScope::LocalOnly) — nothing leaves the machine.
13//! - [`OrgTenant`](EgressScope::OrgTenant) — leaves the machine but stays inside
14//!   the organization's own infrastructure.
15//! - [`ThirdPartyIndex`](EgressScope::ThirdPartyIndex) — content sent to an
16//!   external index / embedding service.
17//! - [`ThirdPartyModel`](EgressScope::ThirdPartyModel) — content sent to an
18//!   external model API.
19//!
20//! The vocabulary is **extensible** by [`Custom`](EgressScope::Custom) scopes,
21//! which MUST be **namespaced** (`vendor:scope-name`) so a custom scope can
22//! never collide with — or be mistaken for — a base class. Everything other
23//! than `local-only` is treated as [off-machine](EgressScope::is_off_machine):
24//! an unrecognized custom scope is conservatively assumed to leave.
25//!
26//! A provider declares its scopes in [`DataFlow::egress_scopes`](crate::DataFlow::egress_scopes);
27//! a scope governs **every frame that provider serves** (there is no per-frame
28//! scope — the serving provider's declaration is the frame's egress class).
29
30use std::fmt;
31
32use serde::{Deserialize, Deserializer, Serialize, Serializer};
33
34/// The wire strings of the four normative base scopes.
35const LOCAL_ONLY: &str = "local-only";
36const ORG_TENANT: &str = "org-tenant";
37const THIRD_PARTY_INDEX: &str = "third-party-index";
38const THIRD_PARTY_MODEL: &str = "third-party-model";
39
40/// Where a provider's served content may go. A closed base vocabulary of four
41/// classes plus namespaced custom extensions (`docs/context-reuse.md` §3).
42///
43/// Serializes to a flat string — the base classes to their canonical kebab-case
44/// names, a [`Custom`](Self::Custom) to its namespaced string — so the wire form
45/// is a plain enum-of-strings any language can produce.
46#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
47pub enum EgressScope {
48    /// Nothing leaves the machine. The only scope compatible with
49    /// `data_flow.egress == false`.
50    LocalOnly,
51    /// Leaves the machine but stays inside the organization's own
52    /// infrastructure.
53    OrgTenant,
54    /// Content sent to an external index / embedding service.
55    ThirdPartyIndex,
56    /// Content sent to an external model API.
57    ThirdPartyModel,
58    /// A namespaced custom scope, e.g. `acme:vector-store`. MUST contain a
59    /// namespace separator `:` with non-empty sides so it can never collide
60    /// with the base vocabulary — see [`is_valid`](Self::is_valid).
61    Custom(String),
62}
63
64impl EgressScope {
65    /// The canonical wire string of this scope.
66    pub fn as_str(&self) -> &str {
67        match self {
68            Self::LocalOnly => LOCAL_ONLY,
69            Self::OrgTenant => ORG_TENANT,
70            Self::ThirdPartyIndex => THIRD_PARTY_INDEX,
71            Self::ThirdPartyModel => THIRD_PARTY_MODEL,
72            Self::Custom(scope) => scope,
73        }
74    }
75
76    /// Parse a wire string: a known base name maps to its variant, anything
77    /// else to [`Custom`](Self::Custom). Never fails — validity of a custom
78    /// scope is a separate, checkable property ([`is_valid`](Self::is_valid)),
79    /// so an unknown-but-well-formed scope round-trips rather than erroring.
80    pub fn from_wire(scope: impl Into<String>) -> Self {
81        let scope = scope.into();
82        match scope.as_str() {
83            LOCAL_ONLY => Self::LocalOnly,
84            ORG_TENANT => Self::OrgTenant,
85            THIRD_PARTY_INDEX => Self::ThirdPartyIndex,
86            THIRD_PARTY_MODEL => Self::ThirdPartyModel,
87            _ => Self::Custom(scope),
88        }
89    }
90
91    /// Whether this scope is one of the four normative base classes.
92    pub fn is_base(&self) -> bool {
93        !matches!(self, Self::Custom(_))
94    }
95
96    /// Whether content under this scope leaves the machine. Everything except
97    /// [`LocalOnly`](Self::LocalOnly) is off-machine, including any custom
98    /// scope — the conservative default is that an unrecognized destination
99    /// leaves, so a host never under-gates a custom scope.
100    pub fn is_off_machine(&self) -> bool {
101        !matches!(self, Self::LocalOnly)
102    }
103
104    /// Whether this scope is well-formed. Base classes are always valid; a
105    /// [`Custom`](Self::Custom) scope MUST be namespaced — exactly one purpose
106    /// of the `:` separator is guaranteeing it cannot be spelled as a bare base
107    /// name. Valid iff it contains a `:` with a non-empty namespace and a
108    /// non-empty name.
109    pub fn is_valid(&self) -> bool {
110        match self {
111            Self::Custom(scope) => match scope.split_once(':') {
112                Some((namespace, name)) => !namespace.is_empty() && !name.is_empty(),
113                None => false,
114            },
115            _ => true,
116        }
117    }
118}
119
120impl fmt::Display for EgressScope {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        f.write_str(self.as_str())
123    }
124}
125
126impl Serialize for EgressScope {
127    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
128        serializer.serialize_str(self.as_str())
129    }
130}
131
132impl<'de> Deserialize<'de> for EgressScope {
133    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
134        let scope = String::deserialize(deserializer)?;
135        Ok(Self::from_wire(scope))
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn base_scopes_round_trip_through_their_canonical_strings() {
145        for (scope, wire) in [
146            (EgressScope::LocalOnly, "local-only"),
147            (EgressScope::OrgTenant, "org-tenant"),
148            (EgressScope::ThirdPartyIndex, "third-party-index"),
149            (EgressScope::ThirdPartyModel, "third-party-model"),
150        ] {
151            assert_eq!(scope.as_str(), wire);
152            let json = serde_json::to_string(&scope).unwrap();
153            assert_eq!(json, format!("\"{wire}\""));
154            let back: EgressScope = serde_json::from_str(&json).unwrap();
155            assert_eq!(back, scope);
156            assert!(scope.is_base() && scope.is_valid());
157        }
158    }
159
160    #[test]
161    fn a_custom_scope_round_trips_as_a_flat_string() {
162        let scope = EgressScope::Custom("acme:vector-store".into());
163        let json = serde_json::to_string(&scope).unwrap();
164        assert_eq!(json, "\"acme:vector-store\"");
165        let back: EgressScope = serde_json::from_str(&json).unwrap();
166        assert_eq!(back, scope);
167        assert!(!scope.is_base());
168        assert!(scope.is_valid());
169    }
170
171    #[test]
172    fn an_unknown_base_like_string_deserializes_to_custom_not_a_base_class() {
173        // A string that isn't one of the four base names is Custom — the
174        // vocabulary is closed at the base level, open by namespacing.
175        let back: EgressScope = serde_json::from_str("\"acme:special\"").unwrap();
176        assert_eq!(back, EgressScope::Custom("acme:special".into()));
177    }
178
179    #[test]
180    fn only_local_only_is_on_machine() {
181        assert!(!EgressScope::LocalOnly.is_off_machine());
182        assert!(EgressScope::OrgTenant.is_off_machine());
183        assert!(EgressScope::ThirdPartyIndex.is_off_machine());
184        assert!(EgressScope::ThirdPartyModel.is_off_machine());
185        // A custom scope is conservatively off-machine.
186        assert!(EgressScope::Custom("acme:sink".into()).is_off_machine());
187    }
188
189    #[test]
190    fn a_non_namespaced_custom_scope_is_invalid() {
191        assert!(!EgressScope::Custom("notnamespaced".into()).is_valid());
192        assert!(!EgressScope::Custom(":no-namespace".into()).is_valid());
193        assert!(!EgressScope::Custom("no-name:".into()).is_valid());
194        assert!(EgressScope::Custom("ns:name".into()).is_valid());
195    }
196}