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) -> &str {
135        &self.namespace
136    }
137
138    /// Returns true if this scope has only a namespace (no domain/workspace/repo).
139    pub fn is_namespace_only(&self) -> bool {
140        self.domain.is_none() && self.workspace_id.is_none() && self.repo_id.is_none()
141    }
142}
143
144impl std::fmt::Display for ScopeKey {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        write!(f, "{}", self.namespace)?;
147        if let Some(d) = &self.domain {
148            write!(f, "/{d}")?;
149        }
150        if let Some(w) = &self.workspace_id {
151            write!(f, "@{w}")?;
152        }
153        if let Some(r) = &self.repo_id {
154            write!(f, "#{r}")?;
155        }
156        Ok(())
157    }
158}
159
160/// Phase status for a feature or behavior.
161///
162/// Used in code comments and metadata to distinguish implemented features
163/// from planned ones. Prevents confusion about what is actually working.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
165#[serde(rename_all = "snake_case")]
166pub enum PhaseStatus {
167    /// Fully implemented and tested.
168    Current,
169    /// Exists only for migration compatibility; will be removed.
170    Compatibility,
171    /// Planned for a future phase; not yet implemented.
172    PhaseGated,
173}
174
175impl PhaseStatus {
176    pub fn as_str(&self) -> &'static str {
177        match self {
178            Self::Current => "current",
179            Self::Compatibility => "compatibility",
180            Self::PhaseGated => "phase_gated",
181        }
182    }
183}
184
185impl std::fmt::Display for PhaseStatus {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        f.write_str(self.as_str())
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn scope_key_equality() {
197        let s1 = Scope::new("ns").with_repo("repo-a");
198        let s2 = Scope::new("ns").with_repo("repo-a");
199        assert_eq!(s1.key(), s2.key());
200    }
201
202    #[test]
203    fn scope_key_inequality_different_repo() {
204        let s1 = Scope::new("ns").with_repo("repo-a");
205        let s2 = Scope::new("ns").with_repo("repo-b");
206        assert_ne!(s1.key(), s2.key());
207    }
208
209    #[test]
210    fn scope_key_display() {
211        let s = Scope::new("prod")
212            .with_domain("code")
213            .with_workspace("ws1")
214            .with_repo("myrepo");
215        assert_eq!(s.key().to_string(), "prod/code@ws1#myrepo");
216    }
217
218    #[test]
219    fn scope_key_display_namespace_only() {
220        let sk = ScopeKey::namespace_only("default");
221        assert_eq!(sk.to_string(), "default");
222    }
223
224    #[test]
225    fn legacy_namespace_roundtrip() {
226        let sk = ScopeKey::from_legacy_namespace("my-namespace");
227        assert_eq!(sk.to_legacy_namespace(), "my-namespace");
228        assert!(sk.is_namespace_only());
229    }
230
231    #[test]
232    fn non_namespace_only_scope() {
233        let sk = Scope::new("ns").with_domain("code").key();
234        assert!(!sk.is_namespace_only());
235    }
236
237    #[test]
238    fn scope_key_ordering() {
239        let a = ScopeKey::namespace_only("aaa");
240        let b = ScopeKey::namespace_only("bbb");
241        assert!(a < b);
242    }
243
244    #[test]
245    fn scope_key_serde_roundtrip() {
246        let sk = Scope::new("ns")
247            .with_domain("code")
248            .with_workspace("ws")
249            .key();
250        let json = serde_json::to_string(&sk).unwrap();
251        let back: ScopeKey = serde_json::from_str(&json).unwrap();
252        assert_eq!(back, sk);
253    }
254
255    #[test]
256    fn scope_key_serde_skips_none() {
257        let sk = ScopeKey::namespace_only("ns");
258        let json = serde_json::to_string(&sk).unwrap();
259        assert!(!json.contains("domain"));
260        assert!(!json.contains("workspace_id"));
261        assert!(!json.contains("repo_id"));
262    }
263}