Skip to main content

stack_ids/
scope.rs

1//! Scope and partition primitives.
2//!
3//! `ScopeKey` is the canonical partition key for the stack. All scope-aware
4//! operations (entity resolution, projection tracking, import routing) use
5//! `ScopeKey` for partitioning.
6//!
7//! ## Namespace-to-ScopeKey migration
8//!
9//! Legacy code uses a bare `namespace: String` as the partition key. The
10//! canonical migration rule is:
11//!
12//! ```text
13//! legacy namespace "foo" → ScopeKey { namespace: "foo", domain: None, workspace_id: None, repo_id: None }
14//! ```
15//!
16//! This mapping is deterministic and reversible via `ScopeKey::from_legacy_namespace()`
17//! and `ScopeKey::to_legacy_namespace()`. All bridge, importer, and test code
18//! must use these functions for namespace↔ScopeKey conversion.
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22
23/// Multi-dimensional scope that bounds every runtime query and projection.
24///
25/// At minimum a `namespace` is required. Optional `domain`, `workspace_id`,
26/// and `repo_id` narrow scope further.
27#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
28pub struct Scope {
29    /// Primary namespace partition.
30    pub namespace: String,
31    /// Logical domain within the namespace (e.g. "code", "docs", "ops").
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub domain: Option<String>,
34    /// Workspace identifier for multi-tenant isolation.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub workspace_id: Option<String>,
37    /// Repository identifier for code-scoped queries.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub repo_id: Option<String>,
40}
41
42impl Scope {
43    /// Create a scope with only a namespace.
44    pub fn new(namespace: impl Into<String>) -> Self {
45        Self {
46            namespace: namespace.into(),
47            domain: None,
48            workspace_id: None,
49            repo_id: None,
50        }
51    }
52
53    /// Builder: set the domain.
54    pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
55        self.domain = Some(domain.into());
56        self
57    }
58
59    /// Builder: set the workspace id.
60    pub fn with_workspace(mut self, id: impl Into<String>) -> Self {
61        self.workspace_id = Some(id.into());
62        self
63    }
64
65    /// Builder: set the repo id.
66    pub fn with_repo(mut self, id: impl Into<String>) -> Self {
67        self.repo_id = Some(id.into());
68        self
69    }
70
71    /// Produce a `ScopeKey` for use in hash maps and equality checks.
72    pub fn key(&self) -> ScopeKey {
73        ScopeKey {
74            namespace: self.namespace.clone(),
75            domain: self.domain.clone(),
76            workspace_id: self.workspace_id.clone(),
77            repo_id: self.repo_id.clone(),
78        }
79    }
80}
81
82/// Compact, hashable representation of all scope dimensions.
83///
84/// This is the canonical partition key for the stack. Two `ScopeKey`s
85/// are equal iff all four fields match exactly.
86///
87/// ## Display format
88///
89/// `namespace[/domain][@workspace_id][#repo_id]`
90///
91/// Examples:
92/// - `prod` — namespace only
93/// - `prod/code` — with domain
94/// - `prod/code@ws1#myrepo` — fully specified
95#[derive(
96    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
97)]
98pub struct ScopeKey {
99    pub namespace: String,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub domain: Option<String>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub workspace_id: Option<String>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub repo_id: Option<String>,
106}
107
108impl ScopeKey {
109    /// Create from just a namespace (all other dimensions None).
110    pub fn namespace_only(ns: impl Into<String>) -> Self {
111        Self {
112            namespace: ns.into(),
113            domain: None,
114            workspace_id: None,
115            repo_id: None,
116        }
117    }
118
119    /// Deterministic migration from a legacy bare namespace string.
120    ///
121    /// This is the canonical namespace→ScopeKey mapping. All bridge, importer,
122    /// and test code must use this for legacy namespace conversion.
123    ///
124    /// Mapping: `"foo"` → `ScopeKey { namespace: "foo", domain: None, workspace_id: None, repo_id: None }`
125    pub fn from_legacy_namespace(namespace: impl Into<String>) -> Self {
126        Self::namespace_only(namespace)
127    }
128
129    /// Reverse mapping: extract the legacy namespace string.
130    ///
131    /// This is only valid for ScopeKeys that were created from a legacy namespace
132    /// (i.e. all dimensions except namespace are None). For multi-dimensional
133    /// scopes, use the `namespace` field directly.
134    pub fn to_legacy_namespace(&self) -> Result<&str, ScopeError> {
135        if self.is_namespace_only() {
136            Ok(&self.namespace)
137        } else {
138            Err(ScopeError::LossyProjection)
139        }
140    }
141
142    pub fn to_legacy_namespace_lossy(
143        &self,
144        _policy: LossyScopePolicy,
145    ) -> (String, ScopeLossReceipt) {
146        (
147            self.namespace.clone(),
148            ScopeLossReceipt {
149                namespace: self.namespace.clone(),
150                dropped_domain: self.domain.clone(),
151                dropped_workspace_id: self.workspace_id.clone(),
152                dropped_repo_id: self.repo_id.clone(),
153            },
154        )
155    }
156
157    /// Returns true if this scope has only a namespace (no domain/workspace/repo).
158    pub fn is_namespace_only(&self) -> bool {
159        self.domain.is_none() && self.workspace_id.is_none() && self.repo_id.is_none()
160    }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum LossyScopePolicy {
165    Authorized,
166}
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct ScopeLossReceipt {
169    pub namespace: String,
170    pub dropped_domain: Option<String>,
171    pub dropped_workspace_id: Option<String>,
172    pub dropped_repo_id: Option<String>,
173}
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum ScopeError {
176    LossyProjection,
177}
178impl std::fmt::Display for ScopeError {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        f.write_str("scope has dimensions not representable by a legacy namespace")
181    }
182}
183impl std::error::Error for ScopeError {}
184
185impl std::fmt::Display for ScopeKey {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        write!(f, "{}", self.namespace)?;
188        if let Some(d) = &self.domain {
189            write!(f, "/{d}")?;
190        }
191        if let Some(w) = &self.workspace_id {
192            write!(f, "@{w}")?;
193        }
194        if let Some(r) = &self.repo_id {
195            write!(f, "#{r}")?;
196        }
197        Ok(())
198    }
199}
200
201/// Phase status for a feature or behavior.
202///
203/// Used in code comments and metadata to distinguish implemented features
204/// from planned ones. Prevents confusion about what is actually working.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
206#[serde(rename_all = "snake_case")]
207pub enum PhaseStatus {
208    /// Fully implemented and tested.
209    Current,
210    /// Exists only for migration compatibility; will be removed.
211    Compatibility,
212    /// Planned for a future phase; not yet implemented.
213    PhaseGated,
214}
215
216impl PhaseStatus {
217    pub fn as_str(&self) -> &'static str {
218        match self {
219            Self::Current => "current",
220            Self::Compatibility => "compatibility",
221            Self::PhaseGated => "phase_gated",
222        }
223    }
224}
225
226impl std::fmt::Display for PhaseStatus {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        f.write_str(self.as_str())
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn scope_key_equality() {
238        let s1 = Scope::new("ns").with_repo("repo-a");
239        let s2 = Scope::new("ns").with_repo("repo-a");
240        assert_eq!(s1.key(), s2.key());
241    }
242
243    #[test]
244    fn scope_key_inequality_different_repo() {
245        let s1 = Scope::new("ns").with_repo("repo-a");
246        let s2 = Scope::new("ns").with_repo("repo-b");
247        assert_ne!(s1.key(), s2.key());
248    }
249
250    #[test]
251    fn scope_key_display() {
252        let s = Scope::new("prod")
253            .with_domain("code")
254            .with_workspace("ws1")
255            .with_repo("myrepo");
256        assert_eq!(s.key().to_string(), "prod/code@ws1#myrepo");
257    }
258
259    #[test]
260    fn scope_key_display_namespace_only() {
261        let sk = ScopeKey::namespace_only("default");
262        assert_eq!(sk.to_string(), "default");
263    }
264
265    #[test]
266    fn legacy_namespace_roundtrip() {
267        let sk = ScopeKey::from_legacy_namespace("my-namespace");
268        assert_eq!(sk.to_legacy_namespace().unwrap(), "my-namespace");
269        assert!(sk.is_namespace_only());
270    }
271
272    #[test]
273    fn non_namespace_only_scope() {
274        let sk = Scope::new("ns").with_domain("code").key();
275        assert!(!sk.is_namespace_only());
276    }
277
278    #[test]
279    fn scope_key_ordering() {
280        let a = ScopeKey::namespace_only("aaa");
281        let b = ScopeKey::namespace_only("bbb");
282        assert!(a < b);
283    }
284
285    #[test]
286    fn scope_key_serde_roundtrip() {
287        let sk = Scope::new("ns")
288            .with_domain("code")
289            .with_workspace("ws")
290            .key();
291        let json = serde_json::to_string(&sk).unwrap();
292        let back: ScopeKey = serde_json::from_str(&json).unwrap();
293        assert_eq!(back, sk);
294    }
295
296    #[test]
297    fn scope_key_serde_skips_none() {
298        let sk = ScopeKey::namespace_only("ns");
299        let json = serde_json::to_string(&sk).unwrap();
300        assert!(!json.contains("domain"));
301        assert!(!json.contains("workspace_id"));
302        assert!(!json.contains("repo_id"));
303    }
304}