Skip to main content

engram/
scope.rs

1//! `Scope` — hierarchical identity context for memory isolation.
2//!
3//! Facts and entities are always owned by a `Scope`. Scopes nest:
4//! org ⊃ user ⊃ session, with an optional agent dimension at every level.
5//! A parent scope can always "see" data owned by child scopes of the same org.
6
7use serde::{Deserialize, Serialize};
8
9/// Hierarchical scope for memory isolation.
10///
11/// The minimum required field is `org_id`. All other fields are optional and
12/// progressively narrow the scope. A `Scope` with only `org_id` is an
13/// "org-level" scope; adding `user_id` narrows it to a user; adding
14/// `session_id` narrows it further to a single conversation session.
15///
16/// `agent_id` is orthogonal — it tracks which agent instance wrote or owns
17/// a piece of memory, without affecting the containment hierarchy.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
19pub struct Scope {
20    /// Organisation (tenant) identifier. Required; defaults to `"default"`.
21    #[serde(default = "default_org")]
22    pub org_id: String,
23
24    /// Agent instance identifier (optional).
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub agent_id: Option<String>,
27
28    /// End-user identifier (optional).
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub user_id: Option<String>,
31
32    /// Conversation session identifier (optional).
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub session_id: Option<String>,
35}
36
37fn default_org() -> String {
38    "default".to_string()
39}
40
41impl Default for Scope {
42    fn default() -> Self {
43        Self {
44            org_id: default_org(),
45            agent_id: None,
46            user_id: None,
47            session_id: None,
48        }
49    }
50}
51
52impl Scope {
53    /// Org-level scope — broadest.
54    pub fn org(org_id: impl Into<String>) -> Self {
55        Self {
56            org_id: org_id.into(),
57            ..Default::default()
58        }
59    }
60
61    /// User-level scope — scoped to a specific end-user within an org.
62    pub fn user(org_id: impl Into<String>, user_id: impl Into<String>) -> Self {
63        Self {
64            org_id: org_id.into(),
65            user_id: Some(user_id.into()),
66            ..Default::default()
67        }
68    }
69
70    /// Session-level scope — scoped to a single conversation session.
71    pub fn session(
72        org_id: impl Into<String>,
73        user_id: impl Into<String>,
74        session_id: impl Into<String>,
75    ) -> Self {
76        Self {
77            org_id: org_id.into(),
78            user_id: Some(user_id.into()),
79            session_id: Some(session_id.into()),
80            ..Default::default()
81        }
82    }
83
84    /// Full scope — all four fields set.
85    pub fn full(
86        org_id: impl Into<String>,
87        agent_id: impl Into<String>,
88        user_id: impl Into<String>,
89        session_id: impl Into<String>,
90    ) -> Self {
91        Self {
92            org_id: org_id.into(),
93            agent_id: Some(agent_id.into()),
94            user_id: Some(user_id.into()),
95            session_id: Some(session_id.into()),
96        }
97    }
98
99    /// Scope depth: 0 = org-only, 1 = +user, 2 = +session, 3 = all four fields.
100    ///
101    /// `agent_id` alone does not increase depth; depth measures the narrowing
102    /// along the org → user → session hierarchy.
103    pub fn depth(&self) -> u8 {
104        match (&self.user_id, &self.session_id, &self.agent_id) {
105            (None, None, None) => 0,
106            (None, None, Some(_)) => 0, // agent_id without user does not deepen
107            (Some(_), None, None) => 1,
108            (Some(_), None, Some(_)) => 1,
109            (Some(_), Some(_), None) => 2,
110            (Some(_), Some(_), Some(_)) => 3,
111            (None, Some(_), _) => 0, // session without user is degenerate; treat as org
112        }
113    }
114
115    /// Returns `true` if `self` is a parent (or equal) scope of `other`.
116    ///
117    /// A scope contains another when:
118    /// - Both belong to the same org.
119    /// - Every field set in `self` matches the corresponding field in `other`.
120    ///   Fields absent in `self` are wildcards.
121    pub fn contains(&self, other: &Scope) -> bool {
122        if self.org_id != other.org_id {
123            return false;
124        }
125        if let Some(ref a) = self.agent_id {
126            if other.agent_id.as_deref() != Some(a.as_str()) {
127                return false;
128            }
129        }
130        if let Some(ref u) = self.user_id {
131            if other.user_id.as_deref() != Some(u.as_str()) {
132                return false;
133            }
134        }
135        if let Some(ref s) = self.session_id {
136            if other.session_id.as_deref() != Some(s.as_str()) {
137                return false;
138            }
139        }
140        true
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn depth_org_only() {
150        assert_eq!(Scope::org("acme").depth(), 0);
151    }
152
153    #[test]
154    fn depth_user() {
155        assert_eq!(Scope::user("acme", "alice").depth(), 1);
156    }
157
158    #[test]
159    fn depth_session() {
160        assert_eq!(Scope::session("acme", "alice", "s1").depth(), 2);
161    }
162
163    #[test]
164    fn depth_full() {
165        assert_eq!(Scope::full("acme", "agent-1", "alice", "s1").depth(), 3);
166    }
167
168    #[test]
169    fn contains_self() {
170        let s = Scope::session("acme", "alice", "s1");
171        assert!(s.contains(&s));
172    }
173
174    #[test]
175    fn org_contains_user() {
176        let org = Scope::org("acme");
177        let user = Scope::user("acme", "alice");
178        assert!(org.contains(&user));
179        assert!(!user.contains(&org));
180    }
181
182    #[test]
183    fn different_org_not_contained() {
184        let a = Scope::org("acme");
185        let b = Scope::org("globex");
186        assert!(!a.contains(&b));
187    }
188
189    #[test]
190    fn serialization_omits_none_fields() {
191        let s = Scope::user("acme", "alice");
192        let json = serde_json::to_string(&s).unwrap();
193        assert!(json.contains("\"org_id\""));
194        assert!(json.contains("\"user_id\""));
195        assert!(!json.contains("\"session_id\""));
196        assert!(!json.contains("\"agent_id\""));
197    }
198
199    #[test]
200    fn default_scope_org_is_default() {
201        let s = Scope::default();
202        assert_eq!(s.org_id, "default");
203    }
204}