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//! The write side refuses one more thing ([`require_writable_ns`]): a
20//! namespace that cannot be spelled back — whitespace, control characters, or
21//! invisible formatting characters. A namespace is otherwise an opaque string
22//! and stays one; this rule exists because minting a namespace is the only
23//! operation with no way to fail, so a typo in one is accepted everywhere and
24//! found nowhere.
25//!
26//! Scopes select **reads only**. Destruction (`FORGET SUBJECT`,
27//! `PURGE OLDER THAN … IN`), grants, retention/anonymization policy, and
28//! point reads (`latest`, `thread_tail`, graph traversals) all take exact
29//! namespaces and refuse patterns loudly — a wildcard must never widen a
30//! destructive or policy surface (root invariant 3).
31
32use crate::error::{AreevError, Result};
33
34/// A parsed namespace scope: exactly one namespace, or a prefix family.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum NsScope {
37    /// An exact namespace name (contains no `*`).
38    Exact(String),
39    /// `<base><sep>*`: the namespace `base` itself plus every namespace
40    /// starting with `base` followed by `sep` — parent + descendants.
41    Prefix {
42        /// The hierarchy root, without the trailing separator (`org`).
43        base: String,
44        /// The separator the caller wrote (`.` in `org.*`, `:` in `agent:*`).
45        sep: char,
46    },
47}
48
49impl NsScope {
50    /// Parse a namespace value from any query surface. `"org"` → `Exact`;
51    /// `"org.*"` → `Prefix{base: "org", sep: '.'}`. Any other placement of
52    /// `*` is a validation error, never a silent exact-match miss.
53    pub fn parse(value: &str) -> Result<NsScope> {
54        if !value.contains('*') {
55            return Ok(NsScope::Exact(value.to_string()));
56        }
57        let Some(head) = value.strip_suffix('*') else {
58            return Err(AreevError::Validation(format!(
59                "namespace pattern \"{value}\": '*' is only valid as the trailing character \
60                 (e.g. \"org.*\")"
61            )));
62        };
63        if head.contains('*') {
64            return Err(AreevError::Validation(format!(
65                "namespace pattern \"{value}\": only one '*' is allowed, as the trailing \
66                 character (e.g. \"org.*\")"
67            )));
68        }
69        let Some(sep) = head.chars().last() else {
70            return Err(AreevError::Validation(
71                "namespace pattern \"*\": a prefix scope needs a base namespace \
72                 (e.g. \"org.*\"); \"every namespace\" is not a recall scope"
73                    .into(),
74            ));
75        };
76        if sep.is_alphanumeric() {
77            return Err(AreevError::Validation(format!(
78                "namespace pattern \"{value}\": '*' must follow a separator — write \
79                 \"{head}.*\" to select \"{head}\" and its descendants ({head}.x, {head}.y.z); \
80                 \"{value}\" would ambiguously match unrelated names sharing the spelling"
81            )));
82        }
83        let base: String = head[..head.len() - sep.len_utf8()].to_string();
84        if base.is_empty() {
85            return Err(AreevError::Validation(format!(
86                "namespace pattern \"{value}\": a prefix scope needs a base namespace before \
87                 the separator (e.g. \"org{sep}*\")"
88            )));
89        }
90        Ok(NsScope::Prefix { base, sep })
91    }
92
93    /// Whether this value even looks like a pattern (contains `*`). Cheap
94    /// pre-check that keeps exact-namespace hot paths at one byte scan.
95    #[inline]
96    pub fn is_pattern(value: &str) -> bool {
97        value.contains('*')
98    }
99
100    /// Does `ns` fall inside this scope? Parent + descendants for a prefix:
101    /// `org.*` matches `org` and `org.sales`, never `organization` or `org:x`.
102    pub fn matches(&self, ns: &str) -> bool {
103        match self {
104            NsScope::Exact(e) => ns == e,
105            NsScope::Prefix { base, sep } => {
106                ns == base
107                    || (ns.len() > base.len()
108                        && ns.starts_with(base.as_str())
109                        && ns[base.len()..].starts_with(*sep))
110            }
111        }
112    }
113}
114
115/// Characters that make a namespace unspellable — invisible on a terminal, in
116/// a diff, and in a review, so a name carrying one is not the name anyone
117/// meant to write. Not a general Unicode category check (no dependency for
118/// one, by workspace policy): the formatting characters that actually collide
119/// with an ASCII identifier, named individually so the list is auditable.
120const UNSPELLABLE: [char; 7] = [
121    '\u{200b}', // ZERO WIDTH SPACE
122    '\u{200c}', // ZERO WIDTH NON-JOINER
123    '\u{200d}', // ZERO WIDTH JOINER
124    '\u{200e}', // LEFT-TO-RIGHT MARK
125    '\u{200f}', // RIGHT-TO-LEFT MARK
126    '\u{00ad}', // SOFT HYPHEN
127    '\u{feff}', // ZERO WIDTH NO-BREAK SPACE (BOM)
128];
129
130/// Guard for MINTING a namespace — a locally authored grain write, the one
131/// operation that brings a namespace into existence rather than naming one
132/// that already does.
133///
134/// Namespaces stay opaque strings (ARCHITECTURE.md, "Namespace prefix scopes
135/// widen reads only"): a host may spell its hierarchy `org.sales.emea`,
136/// `agent:authz` or 部門:営業, and none of that is this crate's business. What
137/// a namespace may not be is **unspellable** — carrying whitespace, a control
138/// character, or an invisible formatting character. Such a name cannot be
139/// typed back at `--ns`, read off a diff, or told apart from the name it was
140/// meant to be, and a write is not refused for it anywhere downstream: the
141/// grain lands, the registry gains a row, and every reader that names the
142/// intended namespace sees nothing.
143///
144/// That is not hypothetical. A bad substitution in a benchmark harness turned
145/// `"agent:harness"` into `"age, build_messagesnt:harness"`; twelve hours of
146/// held-out evaluations were journaled into it, the loop found no runs under
147/// the namespace it reads, recorded no verdict, and proposed no revert for a
148/// lesson that had cost the agent every exact match it had. Nothing failed —
149/// which is the whole problem, and why this is a refusal and not a warning.
150///
151/// Read surfaces deliberately do NOT enforce this ([`require_exact_ns`] is
152/// unchanged): a file written before the rule must stay readable, erasable
153/// and disclosable under whatever name it used, or the rule would strand the
154/// very data it exists to keep findable. Replication replay is exempt for the
155/// same reason the `*` reservation exempts it.
156pub fn require_writable_ns(ns: &str) -> Result<()> {
157    require_exact_ns("a grain write", ns)?;
158    let bad = ns
159        .char_indices()
160        .find(|(_, c)| c.is_whitespace() || c.is_control() || UNSPELLABLE.contains(c));
161    if let Some((at, c)) = bad {
162        return Err(AreevError::Validation(format!(
163            "a grain write takes a spellable namespace (got \"{}\": U+{:04X} at byte {at}): \
164             a namespace is an identifier, and whitespace or an invisible character in one is \
165             a splice, a quoting accident or a bad paste. It would be accepted everywhere and \
166             found nowhere — grains written under it are invisible to every reader that names \
167             the namespace you meant",
168            ns.escape_debug(),
169            c as u32
170        )));
171    }
172    Ok(())
173}
174
175/// Guard for surfaces that take exactly one namespace (writes, destruction,
176/// policy, point reads): refuse a `*`-bearing value loudly instead of letting
177/// it exact-match nothing (silent empty) or select a family (silent widening).
178/// `what` names the operation for the error message.
179pub fn require_exact_ns(what: &str, ns: &str) -> Result<()> {
180    if NsScope::is_pattern(ns) {
181        return Err(AreevError::Validation(format!(
182            "{what} takes an exact namespace, not a pattern (got \"{ns}\"): '*' is reserved \
183             for read scoping (e.g. RECALL … WHERE namespace = \"org.*\")"
184        )));
185    }
186    Ok(())
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn exact_when_no_star() {
195        assert_eq!(NsScope::parse("org").unwrap(), NsScope::Exact("org".into()));
196        assert_eq!(
197            NsScope::parse("org.sales").unwrap(),
198            NsScope::Exact("org.sales".into())
199        );
200        assert_eq!(NsScope::parse("").unwrap(), NsScope::Exact("".into()));
201    }
202
203    #[test]
204    fn prefix_forms_parse() {
205        assert_eq!(
206            NsScope::parse("org.*").unwrap(),
207            NsScope::Prefix { base: "org".into(), sep: '.' }
208        );
209        assert_eq!(
210            NsScope::parse("agent:*").unwrap(),
211            NsScope::Prefix { base: "agent".into(), sep: ':' }
212        );
213        assert_eq!(
214            NsScope::parse("org.sales.*").unwrap(),
215            NsScope::Prefix { base: "org.sales".into(), sep: '.' }
216        );
217        // `-` and `_` are non-alphanumeric, hence valid separators — the same
218        // rule the erasure identity selector uses.
219        assert_eq!(
220            NsScope::parse("areev-*").unwrap(),
221            NsScope::Prefix { base: "areev".into(), sep: '-' }
222        );
223    }
224
225    #[test]
226    fn malformed_patterns_refuse() {
227        for bad in ["*", "org*", "*.org", "org.*x", "o*.sales.*", "**", "org.**"] {
228            let err = NsScope::parse(bad).unwrap_err();
229            assert!(
230                matches!(err, AreevError::Validation(_)),
231                "{bad} should be a validation error, got {err:?}"
232            );
233        }
234    }
235
236    #[test]
237    fn separator_only_pattern_needs_a_base() {
238        assert!(NsScope::parse(".*").is_err());
239        assert!(NsScope::parse(":*").is_err());
240    }
241
242    #[test]
243    fn matches_parent_and_descendants_only() {
244        let s = NsScope::parse("org.*").unwrap();
245        assert!(s.matches("org"), "parent is included");
246        assert!(s.matches("org.sales"));
247        assert!(s.matches("org.sales.emea"));
248        assert!(!s.matches("organization"), "separator required");
249        assert!(!s.matches("org:x"), "the caller chose '.' as the hierarchy");
250        assert!(!s.matches("orgs"));
251        assert!(!s.matches(""));
252        assert!(!s.matches("xorg.sales"));
253    }
254
255    #[test]
256    fn matches_with_colon_separator() {
257        let s = NsScope::parse("agent:*").unwrap();
258        assert!(s.matches("agent"));
259        assert!(s.matches("agent:authz"));
260        assert!(!s.matches("agent.authz"));
261        assert!(!s.matches("agents"));
262    }
263
264    #[test]
265    fn unicode_separator_boundary_is_char_correct() {
266        // A multi-byte separator must slice on the char boundary, not byte len 1.
267        let s = NsScope::parse("org→*").unwrap();
268        assert_eq!(s, NsScope::Prefix { base: "org".into(), sep: '→' });
269        assert!(s.matches("org"));
270        assert!(s.matches("org→x"));
271        assert!(!s.matches("org.x"));
272    }
273
274    #[test]
275    fn exact_matches_exactly() {
276        let s = NsScope::parse("org").unwrap();
277        assert!(s.matches("org"));
278        assert!(!s.matches("org.sales"));
279        assert!(!s.matches("or"));
280    }
281
282    #[test]
283    fn writable_ns_accepts_the_names_hosts_actually_use() {
284        for ok in [
285            "",
286            "caller",
287            "agent:harness",
288            "org.sales.emea",
289            "claude-code",
290            "deal.energy.42",
291            "retention:org.sales",
292            "部門:営業",
293            "org→x", // an arbitrary separator stays the host's business
294        ] {
295            assert!(require_writable_ns(ok).is_ok(), "{ok:?} should be writable");
296        }
297    }
298
299    #[test]
300    fn writable_ns_refuses_the_unspellable() {
301        // The one that shipped: a bad substitution spliced an import fragment
302        // into the constant, and every write under it was accepted in silence.
303        let err = require_writable_ns("age, build_messagesnt:harness").unwrap_err();
304        assert!(err.to_string().starts_with("VAL-E001"), "{err}");
305        assert!(err.to_string().contains("U+0020"), "names the character: {err}");
306
307        for bad in [
308            "agent harness",  // space
309            "agent\tharness", // tab
310            "agent\nharness", // newline
311            " caller",        // leading
312            "caller ",        // trailing
313            " ",              // whitespace only
314            "agent\u{200b}harness", // zero width space — looks identical
315            "agent\u{feff}harness", // BOM
316            "agent\u{00ad}harness", // soft hyphen
317            "agent\u{0007}harness", // control
318        ] {
319            let err = require_writable_ns(bad).unwrap_err();
320            assert!(
321                matches!(err, AreevError::Validation(_)),
322                "{bad:?} should be a validation error, got {err:?}"
323            );
324        }
325    }
326
327    #[test]
328    fn writable_ns_still_refuses_the_reserved_star() {
329        assert!(require_writable_ns("org.*").is_err());
330        assert!(require_writable_ns("o*rg").is_err());
331    }
332
333    #[test]
334    fn writable_ns_error_does_not_leak_a_raw_control_character() {
335        // The message quotes the namespace back; escaped, or a name carrying a
336        // newline or an escape sequence would forge lines in the log that
337        // records the refusal.
338        let msg = require_writable_ns("a\nb\u{1b}[31m").unwrap_err().to_string();
339        assert!(!msg.contains('\n'), "no raw newline: {msg:?}");
340        assert!(!msg.contains('\u{1b}'), "no raw escape: {msg:?}");
341        assert!(msg.contains("\\n"), "escaped instead: {msg:?}");
342    }
343
344    #[test]
345    fn read_surfaces_still_accept_a_legacy_unspellable_name() {
346        // A file written before the rule must stay readable, erasable and
347        // disclosable under the name it used — otherwise the rule strands the
348        // data it exists to keep findable.
349        assert!(require_exact_ns("forget_subject", "age, build_messagesnt:harness").is_ok());
350        assert!(require_exact_ns("subject_report", "agent harness").is_ok());
351        assert!(NsScope::parse("agent harness").is_ok());
352    }
353
354    #[test]
355    fn require_exact_refuses_patterns() {
356        assert!(require_exact_ns("latest", "org").is_ok());
357        let err = require_exact_ns("PURGE", "org.*").unwrap_err();
358        assert!(err.to_string().starts_with("VAL-E001"), "{err}");
359        assert!(require_exact_ns("forget_subject", "o*rg").is_err());
360    }
361}