Skip to main content

lunaris_core/
scope.rs

1//! `Scope` newtype — the primary partition key for multi-agent / multi-tenant
2//! isolation in Lunaris v0.2 (RFC 0001).
3//!
4//! Every primitive write-op path tags the row with the scope. Two scopes
5//! compare equal iff their string forms match byte-for-byte. There is **no
6//! implicit fallback to a "default" scope** — a `Scope` must be constructed
7//! explicitly.
8//!
9//! ## Validation
10//!
11//! The string must match `^[A-Za-z0-9_\-.]{1,128}$` (enforced by
12//! [`Scope::new`]). The unchecked constructor `Scope::from_trusted` is
13//! `pub(crate)` and is only used by trusted internal call sites (e.g.,
14//! deserialization of previously-validated wire data).
15//!
16//! ## Examples
17//!
18//! ```
19//! use lunaris_core::Scope;
20//! let s = Scope::new("acme.agent-42").unwrap();
21//! assert_eq!(s.as_str(), "acme.agent-42");
22//! ```
23
24use smol_str::SmolStr;
25use thiserror::Error;
26
27/// Validation regex fragment — kept as a const so backends and tests can
28/// re-use it without duplicating the pattern.
29///
30/// Pattern: `^[A-Za-z0-9_\-.]{1,128}$`
31///
32/// RC-2 (v0.2.1): `:` was removed from the allowed alphabet to close the
33/// SCAN prefix delimiter ambiguity. The KV key format
34/// `lunaris:{scope}:{kind}:{ulid}` uses `:` as the field separator, so a
35/// scope like `"a:episode"` previously produced byte-identical bytes to
36/// `episode_prefix(&Scope("a"))` and `SCAN MATCH <prefix>*` under the
37/// colliding scope on Moon could enumerate the other scope's episodes.
38/// Dropping `:` makes the format unambiguous at the type level.
39///
40/// **Breaking change** for any v0.2.0 deployment that minted scope strings
41/// containing `:` (e.g. `acme:agent-42`). The recommended replacement is
42/// `.` or `-` (`acme.agent-42`). Enforcement is at the type level: `Scope::new`
43/// is the only constructor, and the hand-rolled `Deserialize` re-runs it on
44/// wire bytes. (v0.2.1 also tightened a matching Postgres CHECK constraint;
45/// that backend was removed in 0.7.0.)
46const VALID_SCOPE_CHARS: fn(char) -> bool =
47    |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.');
48const MAX_SCOPE_LEN: usize = 128;
49
50/// A partition key for multi-agent / multi-tenant isolation.
51///
52/// `Scope` is a thin newtype around [`SmolStr`] (inline up to 23 bytes — most
53/// scope identifiers fit). Two scopes compare equal iff their string forms
54/// match byte-for-byte. There is **no implicit fallback to a "default"
55/// scope** — a `Scope` must be constructed explicitly.
56///
57/// # Validation
58///
59/// The string must match `^[A-Za-z0-9_\-.]{1,128}$`. This is enforced by
60/// [`Scope::new`]; the unchecked constructor is `pub(crate)` and only used by
61/// trusted internal call sites (deserialization of validated wire data).
62///
63/// # Examples
64///
65/// ```
66/// use lunaris_core::Scope;
67/// let s = Scope::new("acme.agent-42").unwrap();
68/// assert_eq!(s.as_str(), "acme.agent-42");
69/// ```
70#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)]
71#[serde(transparent)]
72pub struct Scope(SmolStr);
73
74/// RC-4 (v0.2 release-gate review): re-validate on the wire boundary.
75///
76/// The previous derived `Deserialize` with `#[serde(transparent)]` accepted
77/// any string, bypassing [`Scope::new`]'s regex. Internal deserialization
78/// sites (rows fetched from a future cloud-API backend, MQ envelopes that
79/// gain a `scope` field, etc.) would have trusted attacker-controlled bytes.
80/// This impl forces every wire-side `Scope` to clear [`Scope::new`].
81impl<'de> serde::Deserialize<'de> for Scope {
82    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
83        let s = SmolStr::deserialize(d)?;
84        Scope::new(s.as_str()).map_err(serde::de::Error::custom)
85    }
86}
87
88impl Scope {
89    /// Construct a `Scope` from `s`, enforcing the validation regex
90    /// `^[A-Za-z0-9_\-.]{1,128}$`.
91    ///
92    /// Returns `Err(ScopeError::Invalid)` on empty string, string longer than
93    /// 128 bytes, or any character outside the allowed set.
94    pub fn new(s: impl AsRef<str>) -> Result<Self, ScopeError> {
95        let s = s.as_ref();
96        if s.is_empty() || s.len() > MAX_SCOPE_LEN || !s.chars().all(VALID_SCOPE_CHARS) {
97            return Err(ScopeError::Invalid(s.to_string()));
98        }
99        Ok(Self(SmolStr::new(s)))
100    }
101
102    /// Borrow the scope as a string slice.
103    #[inline]
104    pub fn as_str(&self) -> &str {
105        self.0.as_str()
106    }
107
108    /// Borrow the scope as a byte slice.
109    #[inline]
110    pub fn as_bytes(&self) -> &[u8] {
111        self.0.as_bytes()
112    }
113
114    /// Trusted constructor for internal use at call sites that have already
115    /// validated the string (e.g., deserialization of a row fetched from
116    /// the validated database column). Caller is responsible for ensuring
117    /// the invariant `^[A-Za-z0-9_\-.]{1,128}$` holds.
118    ///
119    /// Used when deserializing scope values that came back out of storage —
120    /// they were validated by `Scope::new` on the way in.
121    #[allow(dead_code)]
122    #[inline]
123    pub(crate) fn from_trusted(s: &str) -> Self {
124        Self(SmolStr::new(s))
125    }
126
127    /// Development / migration helper. Returns a `Scope` whose value is
128    /// `"_dev_"`. Use this at Wave 0 call sites where the real scope has not
129    /// yet been threaded through (Wave 1 will replace these with actual
130    /// per-agent scopes).
131    ///
132    /// **This function is intentionally `#[doc(hidden)]`** — it is a
133    /// migration crutch and MUST NOT appear in public API documentation.
134    /// Callers outside this crate should not use it in production code.
135    #[doc(hidden)]
136    pub fn dev() -> Self {
137        // SAFETY: "_dev_" matches ^[A-Za-z0-9_\-.]{1,128}$ by inspection.
138        Self(SmolStr::new("_dev_"))
139    }
140
141    /// Is `segment` a legal sub-partition segment?
142    ///
143    /// A segment is a non-empty run of `[A-Za-z0-9_-]`. Note `.` is EXCLUDED
144    /// here even though [`Scope::new`] permits it in a whole scope, because
145    /// `.` is the level separator (see [`child`](Scope::child)): a segment
146    /// carrying its own `.` would forge an extra level. `:` and `/` are
147    /// likewise excluded so a composed segment can never byte-alias the
148    /// `lunaris:{scope}:{kind}:{ulid}` KV format.
149    #[inline]
150    pub fn is_valid_segment(segment: &str) -> bool {
151        !segment.is_empty()
152            && segment.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
153    }
154
155    /// Compose a child sub-partition by appending a validated `.{segment}`.
156    ///
157    /// `segment` must satisfy [`is_valid_segment`](Scope::is_valid_segment);
158    /// the full composed string is then re-validated by [`Scope::new`]
159    /// (alphabet + 128-byte cap). Consequently `self.as_str()` is ALWAYS a
160    /// byte-prefix of the returned child — this is the load-bearing isolation
161    /// guarantee for multi-level memory (RFC 0001 sub-partitions): a caller
162    /// can only NARROW into a sub-partition of its own scope, never escape to
163    /// a sibling or parent.
164    pub fn child(&self, segment: &str) -> Result<Scope, ScopeError> {
165        if !Self::is_valid_segment(segment) {
166            return Err(ScopeError::Invalid(segment.to_string()));
167        }
168        Scope::new(format!("{}.{segment}", self.0.as_str()))
169    }
170}
171
172impl std::fmt::Display for Scope {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        f.write_str(self.0.as_str())
175    }
176}
177
178impl AsRef<str> for Scope {
179    fn as_ref(&self) -> &str {
180        self.0.as_str()
181    }
182}
183
184/// The canonical memory-partition levels, composed UNDER the JWT base scope
185/// in this fixed order (`User` → `Agent` → `Session`). Each carries a
186/// one-char disambiguating tag so the composed scope is self-describing and
187/// collision-resistant: `{base}.u-{user}.a-{agent}.s-{session}`, including
188/// only the levels whose id is present.
189#[derive(Clone, Copy, Debug, PartialEq, Eq)]
190pub enum MemoryLevel {
191    /// Per-user memory space (tag `u`).
192    User,
193    /// Per-agent memory space (tag `a`).
194    Agent,
195    /// Per-session / run memory space (tag `s`).
196    Session,
197}
198
199impl MemoryLevel {
200    /// The one-char tag prefixed to a level id in the composed scope.
201    #[inline]
202    pub fn tag(&self) -> &'static str {
203        match self {
204            MemoryLevel::User => "u",
205            MemoryLevel::Agent => "a",
206            MemoryLevel::Session => "s",
207        }
208    }
209}
210
211/// Compose the JWT-bound `base` scope with optional user/agent/session ids,
212/// in the canonical [`MemoryLevel`] order, into the bound partition.
213///
214/// Each present id becomes a `{tag}-{id}` segment appended via
215/// [`Scope::child`], so `base.as_str()` is ALWAYS a byte-prefix of the
216/// result (sub-partition, never escape). All-`None` returns `base.clone()`
217/// — back-compat: operate at the base scope, today's behavior.
218///
219/// An id outside the segment alphabet (`[A-Za-z0-9_-]`, e.g. one carrying a
220/// `.`/`:`/`/`) or one whose composition exceeds the 128-byte scope cap
221/// yields `Err(ScopeError::Invalid)`. The HTTP layer pre-screens ids with
222/// [`Scope::is_valid_segment`] so it can distinguish `invalid_level_segment`
223/// from `scope_too_long` (only the length cap can fail once ids are clean).
224pub fn compose_levels(
225    base: &Scope,
226    user: Option<&str>,
227    agent: Option<&str>,
228    session: Option<&str>,
229) -> Result<Scope, ScopeError> {
230    let mut scope = base.clone();
231    for (level, id) in
232        [(MemoryLevel::User, user), (MemoryLevel::Agent, agent), (MemoryLevel::Session, session)]
233    {
234        if let Some(id) = id {
235            scope = scope.child(&format!("{}-{id}", level.tag()))?;
236        }
237    }
238    Ok(scope)
239}
240
241/// Error returned when constructing an invalid [`Scope`].
242#[derive(Debug, Error)]
243pub enum ScopeError {
244    /// The string is empty, too long (> 128 chars), or contains a character
245    /// outside `[A-Za-z0-9_\-.]`.
246    #[error("scope must be 1..=128 chars of [A-Za-z0-9_\\-.]; got {0:?}")]
247    Invalid(String),
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use std::collections::HashSet;
254
255    // ── valid construction ────────────────────────────────────────────────────
256
257    #[test]
258    fn valid_scope_roundtrip() {
259        let s = Scope::new("acme.agent-42").unwrap();
260        assert_eq!(s.as_str(), "acme.agent-42");
261    }
262
263    #[test]
264    fn single_char_is_accepted() {
265        assert!(Scope::new("a").is_ok());
266        assert!(Scope::new("Z").is_ok());
267        assert!(Scope::new("0").is_ok());
268        assert!(Scope::new("_").is_ok());
269    }
270
271    #[test]
272    fn max_length_scope_is_accepted() {
273        let at_limit = "a".repeat(128);
274        assert!(Scope::new(&at_limit).is_ok());
275    }
276
277    #[test]
278    fn all_regex_specials_individually_accepted() {
279        // Every character outside alphanumerics that the regex permits.
280        assert!(Scope::new("under_score").is_ok(), "underscore must be valid");
281        assert!(Scope::new("hy-phen").is_ok(), "hyphen must be valid");
282        assert!(Scope::new("do.t").is_ok(), "dot must be valid");
283        // Combined in one identifier — same as the pattern A0._-
284        assert!(Scope::new("A0._.-").is_ok(), "all specials together must be valid");
285    }
286
287    /// RC-2 (v0.2.1): `:` is no longer in the allowed alphabet. This test
288    /// pins the breaking-change boundary so any future relaxation that
289    /// re-adds `:` will fail loudly here AND in the doc comment.
290    #[test]
291    fn colon_is_rejected() {
292        assert!(Scope::new("co:lon").is_err(), "colon MUST be rejected post-v0.2.1");
293        assert!(
294            Scope::new("a:episode").is_err(),
295            "the SCAN-aliasing scope form `a:episode` MUST be rejected at the type level"
296        );
297        assert!(Scope::new("tenant:1").is_err());
298        // Bare colon at the end / start.
299        assert!(Scope::new(":lead").is_err());
300        assert!(Scope::new("trail:").is_err());
301    }
302
303    #[test]
304    fn valid_chars_accepted() {
305        assert!(Scope::new("org.team_agent-1.v2").is_ok());
306        assert!(Scope::new("_dev_").is_ok());
307    }
308
309    // ── rejection ────────────────────────────────────────────────────────────
310
311    #[test]
312    fn empty_scope_is_rejected() {
313        let err = Scope::new("").unwrap_err();
314        // ScopeError::Invalid must carry the (empty) bad input.
315        assert!(matches!(err, ScopeError::Invalid(ref s) if s.is_empty()));
316    }
317
318    #[test]
319    fn one_over_max_length_is_rejected() {
320        let too_long = "a".repeat(129);
321        let err = Scope::new(&too_long).unwrap_err();
322        // Error must carry the full bad string so callers can surface it.
323        assert!(matches!(err, ScopeError::Invalid(ref s) if s.len() == 129));
324    }
325
326    #[test]
327    fn invalid_chars_rejected() {
328        for bad in &[
329            "has space",
330            " leading",
331            "trailing ",
332            "\thas_tab",
333            "has/slash",
334            "has@at",
335            "has#hash",
336            "has!bang",
337            "has+plus",
338            "has=eq",
339            "has[bracket",
340            "has{brace",
341            "has\"quote",
342            "has\\backslash",
343            // RC-2 (v0.2.1) — colon is no longer in the allowed alphabet.
344            "has:colon",
345        ] {
346            let err = Scope::new(*bad);
347            assert!(err.is_err(), "expected rejection for {:?} but got Ok", bad);
348            // Verify the error carries the exact rejected input.
349            let ScopeError::Invalid(carried) = err.unwrap_err();
350            assert_eq!(&carried, bad, "ScopeError::Invalid must carry the exact bad input");
351        }
352    }
353
354    #[test]
355    fn whitespace_not_trimmed_or_silently_accepted() {
356        // Leading/trailing whitespace is NOT trimmed — it is rejected outright.
357        assert!(Scope::new(" acme").is_err());
358        assert!(Scope::new("acme ").is_err());
359        assert!(Scope::new(" ").is_err());
360    }
361
362    // ── dev() helper ─────────────────────────────────────────────────────────
363
364    #[test]
365    fn dev_scope_is_valid() {
366        let s = Scope::dev();
367        assert_eq!(s.as_str(), "_dev_");
368        // dev() must produce a value that also passes Scope::new — it is a real Scope.
369        assert!(Scope::new("_dev_").is_ok());
370    }
371
372    // ── serde ─────────────────────────────────────────────────────────────────
373
374    #[test]
375    fn scope_serde_transparent() {
376        let s = Scope::new("tenant-1").unwrap();
377        let json = serde_json::to_string(&s).unwrap();
378        assert_eq!(json, r#""tenant-1""#);
379        let back: Scope = serde_json::from_str(&json).unwrap();
380        assert_eq!(back, s);
381    }
382
383    #[test]
384    fn serde_rejects_invalid_scope_string() {
385        // RC-4 (v0.2 release-gate): custom Deserialize now re-validates against
386        // Scope::new. Any wire string that fails the regex must fail deserialize.
387        let result: Result<Scope, _> = serde_json::from_str(r#""has space""#);
388        assert!(result.is_err(), "invalid scope must be rejected at deserialize");
389
390        // Sanity: a valid wire string still round-trips.
391        let ok: Scope = serde_json::from_str(r#""acme.agent-1""#).unwrap();
392        assert_eq!(ok.as_str(), "acme.agent-1");
393
394        // RC-2: a wire string with `:` is now rejected at deserialize
395        // (regression-pin for the v0.2.1 regex tightening).
396        let colon: Result<Scope, _> = serde_json::from_str(r#""acme:agent-1""#);
397        assert!(colon.is_err(), "post-v0.2.1: colon must be rejected on the wire too");
398
399        // And a too-long string is rejected.
400        let too_long = format!("\"{}\"", "a".repeat(129));
401        let bad: Result<Scope, _> = serde_json::from_str(&too_long);
402        assert!(bad.is_err(), "129-char scope must be rejected at deserialize");
403    }
404
405    // ── equality / hash ──────────────────────────────────────────────────────
406
407    #[test]
408    fn scope_equality_is_byte_exact() {
409        let a = Scope::new("Tenant").unwrap();
410        let b = Scope::new("tenant").unwrap();
411        assert_ne!(a, b);
412    }
413
414    #[test]
415    fn equal_scopes_have_equal_hashes() {
416        let a = Scope::new("acme.agent-1").unwrap();
417        let b = Scope::new("acme.agent-1").unwrap();
418        assert_eq!(a, b);
419        // Hash must agree with Eq: a == b => hash(a) == hash(b).
420        let mut set = HashSet::new();
421        set.insert(a);
422        assert!(set.contains(&b), "equal Scope must hash to the same bucket");
423    }
424
425    #[test]
426    fn distinct_scopes_are_not_equal() {
427        let a = Scope::new("acme.agent-1").unwrap();
428        let b = Scope::new("acme.agent-2").unwrap();
429        assert_ne!(a, b);
430    }
431
432    // ── ordering (Ord / PartialOrd) ──────────────────────────────────────────
433
434    #[test]
435    fn scope_ord_is_lexicographic() {
436        let a = Scope::new("a").unwrap();
437        let b = Scope::new("b").unwrap();
438        assert!(a < b);
439        assert!(b > a);
440        assert_eq!(a.cmp(&a), std::cmp::Ordering::Equal);
441    }
442
443    #[test]
444    fn scope_sort_is_stable() {
445        let mut scopes: Vec<Scope> = vec![
446            Scope::new("z.agent").unwrap(),
447            Scope::new("a.agent").unwrap(),
448            Scope::new("m.agent").unwrap(),
449        ];
450        scopes.sort();
451        assert_eq!(scopes[0].as_str(), "a.agent");
452        assert_eq!(scopes[1].as_str(), "m.agent");
453        assert_eq!(scopes[2].as_str(), "z.agent");
454    }
455
456    // ── display ──────────────────────────────────────────────────────────────
457
458    #[test]
459    fn display_matches_as_str() {
460        let s = Scope::new("org.team_agent-1.v2").unwrap();
461        assert_eq!(format!("{s}"), s.as_str());
462    }
463
464    // ── as_ref ───────────────────────────────────────────────────────────────
465
466    #[test]
467    fn as_ref_str_matches_as_str() {
468        let s = Scope::new("acme.agent-42").unwrap();
469        let r: &str = s.as_ref();
470        assert_eq!(r, s.as_str());
471    }
472}