Skip to main content

areev_core/
ns.rs

1//! Namespace scopes — the `"org.*"` wildcard convention shared by every
2//! query surface (store recall, CAL `WHERE namespace`, MCP, CLI `--ns`).
3//!
4//! A namespace value ending in `*` is a PREFIX SCOPE rather than an exact
5//! name: `"org.*"` selects the namespace `org` itself plus every descendant
6//! reached through the separator the caller wrote (`org.sales`,
7//! `org.sales.emea` — but never `organization`, and never `org:x`, whose
8//! hierarchy uses a different separator). The `*` must be the trailing
9//! character and must follow a non-alphanumeric separator, which is what
10//! keeps `org*` (ambiguous with `organization`) unspellable. Bare `*` is
11//! refused too — "every namespace" stays an explicit authorization concept
12//! (grants), not a recall convenience.
13//!
14//! Because `*` carries this meaning on the read side, it is RESERVED in
15//! namespace names on the write side: the store refuses to add a grain whose
16//! namespace contains `*` (`VAL-E001`). Replication replay deliberately does
17//! not enforce this, so files written before the reservation stay importable.
18//!
19//! Scopes select **reads only**. Destruction (`FORGET SUBJECT`,
20//! `PURGE OLDER THAN … IN`), grants, retention/anonymization policy, and
21//! point reads (`latest`, `thread_tail`, graph traversals) all take exact
22//! namespaces and refuse patterns loudly — a wildcard must never widen a
23//! destructive or policy surface (root invariant 3).
24
25use crate::error::{AreevError, Result};
26
27/// A parsed namespace scope: exactly one namespace, or a prefix family.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum NsScope {
30    /// An exact namespace name (contains no `*`).
31    Exact(String),
32    /// `<base><sep>*`: the namespace `base` itself plus every namespace
33    /// starting with `base` followed by `sep` — parent + descendants.
34    Prefix {
35        /// The hierarchy root, without the trailing separator (`org`).
36        base: String,
37        /// The separator the caller wrote (`.` in `org.*`, `:` in `agent:*`).
38        sep: char,
39    },
40}
41
42impl NsScope {
43    /// Parse a namespace value from any query surface. `"org"` → `Exact`;
44    /// `"org.*"` → `Prefix{base: "org", sep: '.'}`. Any other placement of
45    /// `*` is a validation error, never a silent exact-match miss.
46    pub fn parse(value: &str) -> Result<NsScope> {
47        if !value.contains('*') {
48            return Ok(NsScope::Exact(value.to_string()));
49        }
50        let Some(head) = value.strip_suffix('*') else {
51            return Err(AreevError::Validation(format!(
52                "namespace pattern \"{value}\": '*' is only valid as the trailing character \
53                 (e.g. \"org.*\")"
54            )));
55        };
56        if head.contains('*') {
57            return Err(AreevError::Validation(format!(
58                "namespace pattern \"{value}\": only one '*' is allowed, as the trailing \
59                 character (e.g. \"org.*\")"
60            )));
61        }
62        let Some(sep) = head.chars().last() else {
63            return Err(AreevError::Validation(
64                "namespace pattern \"*\": a prefix scope needs a base namespace \
65                 (e.g. \"org.*\"); \"every namespace\" is not a recall scope"
66                    .into(),
67            ));
68        };
69        if sep.is_alphanumeric() {
70            return Err(AreevError::Validation(format!(
71                "namespace pattern \"{value}\": '*' must follow a separator — write \
72                 \"{head}.*\" to select \"{head}\" and its descendants ({head}.x, {head}.y.z); \
73                 \"{value}\" would ambiguously match unrelated names sharing the spelling"
74            )));
75        }
76        let base: String = head[..head.len() - sep.len_utf8()].to_string();
77        if base.is_empty() {
78            return Err(AreevError::Validation(format!(
79                "namespace pattern \"{value}\": a prefix scope needs a base namespace before \
80                 the separator (e.g. \"org{sep}*\")"
81            )));
82        }
83        Ok(NsScope::Prefix { base, sep })
84    }
85
86    /// Whether this value even looks like a pattern (contains `*`). Cheap
87    /// pre-check that keeps exact-namespace hot paths at one byte scan.
88    #[inline]
89    pub fn is_pattern(value: &str) -> bool {
90        value.contains('*')
91    }
92
93    /// Does `ns` fall inside this scope? Parent + descendants for a prefix:
94    /// `org.*` matches `org` and `org.sales`, never `organization` or `org:x`.
95    pub fn matches(&self, ns: &str) -> bool {
96        match self {
97            NsScope::Exact(e) => ns == e,
98            NsScope::Prefix { base, sep } => {
99                ns == base
100                    || (ns.len() > base.len()
101                        && ns.starts_with(base.as_str())
102                        && ns[base.len()..].starts_with(*sep))
103            }
104        }
105    }
106}
107
108/// Guard for surfaces that take exactly one namespace (writes, destruction,
109/// policy, point reads): refuse a `*`-bearing value loudly instead of letting
110/// it exact-match nothing (silent empty) or select a family (silent widening).
111/// `what` names the operation for the error message.
112pub fn require_exact_ns(what: &str, ns: &str) -> Result<()> {
113    if NsScope::is_pattern(ns) {
114        return Err(AreevError::Validation(format!(
115            "{what} takes an exact namespace, not a pattern (got \"{ns}\"): '*' is reserved \
116             for read scoping (e.g. RECALL … WHERE namespace = \"org.*\")"
117        )));
118    }
119    Ok(())
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn exact_when_no_star() {
128        assert_eq!(NsScope::parse("org").unwrap(), NsScope::Exact("org".into()));
129        assert_eq!(
130            NsScope::parse("org.sales").unwrap(),
131            NsScope::Exact("org.sales".into())
132        );
133        assert_eq!(NsScope::parse("").unwrap(), NsScope::Exact("".into()));
134    }
135
136    #[test]
137    fn prefix_forms_parse() {
138        assert_eq!(
139            NsScope::parse("org.*").unwrap(),
140            NsScope::Prefix { base: "org".into(), sep: '.' }
141        );
142        assert_eq!(
143            NsScope::parse("agent:*").unwrap(),
144            NsScope::Prefix { base: "agent".into(), sep: ':' }
145        );
146        assert_eq!(
147            NsScope::parse("org.sales.*").unwrap(),
148            NsScope::Prefix { base: "org.sales".into(), sep: '.' }
149        );
150        // `-` and `_` are non-alphanumeric, hence valid separators — the same
151        // rule the erasure identity selector uses.
152        assert_eq!(
153            NsScope::parse("areev-*").unwrap(),
154            NsScope::Prefix { base: "areev".into(), sep: '-' }
155        );
156    }
157
158    #[test]
159    fn malformed_patterns_refuse() {
160        for bad in ["*", "org*", "*.org", "org.*x", "o*.sales.*", "**", "org.**"] {
161            let err = NsScope::parse(bad).unwrap_err();
162            assert!(
163                matches!(err, AreevError::Validation(_)),
164                "{bad} should be a validation error, got {err:?}"
165            );
166        }
167    }
168
169    #[test]
170    fn separator_only_pattern_needs_a_base() {
171        assert!(NsScope::parse(".*").is_err());
172        assert!(NsScope::parse(":*").is_err());
173    }
174
175    #[test]
176    fn matches_parent_and_descendants_only() {
177        let s = NsScope::parse("org.*").unwrap();
178        assert!(s.matches("org"), "parent is included");
179        assert!(s.matches("org.sales"));
180        assert!(s.matches("org.sales.emea"));
181        assert!(!s.matches("organization"), "separator required");
182        assert!(!s.matches("org:x"), "the caller chose '.' as the hierarchy");
183        assert!(!s.matches("orgs"));
184        assert!(!s.matches(""));
185        assert!(!s.matches("xorg.sales"));
186    }
187
188    #[test]
189    fn matches_with_colon_separator() {
190        let s = NsScope::parse("agent:*").unwrap();
191        assert!(s.matches("agent"));
192        assert!(s.matches("agent:authz"));
193        assert!(!s.matches("agent.authz"));
194        assert!(!s.matches("agents"));
195    }
196
197    #[test]
198    fn unicode_separator_boundary_is_char_correct() {
199        // A multi-byte separator must slice on the char boundary, not byte len 1.
200        let s = NsScope::parse("org→*").unwrap();
201        assert_eq!(s, NsScope::Prefix { base: "org".into(), sep: '→' });
202        assert!(s.matches("org"));
203        assert!(s.matches("org→x"));
204        assert!(!s.matches("org.x"));
205    }
206
207    #[test]
208    fn exact_matches_exactly() {
209        let s = NsScope::parse("org").unwrap();
210        assert!(s.matches("org"));
211        assert!(!s.matches("org.sales"));
212        assert!(!s.matches("or"));
213    }
214
215    #[test]
216    fn require_exact_refuses_patterns() {
217        assert!(require_exact_ns("latest", "org").is_ok());
218        let err = require_exact_ns("PURGE", "org.*").unwrap_err();
219        assert!(err.to_string().starts_with("VAL-E001"), "{err}");
220        assert!(require_exact_ns("forget_subject", "o*rg").is_err());
221    }
222}