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}