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}