Skip to main content

entelix_memory/
namespace.rs

1//! `Namespace` — F2 mitigation. Every memory access is keyed by a
2//! `(tenant_id, scope)` pair, with `tenant_id` mandatory at the type
3//! level so cross-tenant data leakage is structurally impossible
4//! (invariant 11).
5//!
6//! There is no `Default` impl, no zero-arg constructor, and no
7//! "unsafe-tenantless" escape hatch. The `tenant_id` is the
8//! validating [`entelix_core::TenantId`] newtype, so a wire payload
9//! carrying an empty tenant — including a rendered key starting with
10//! `":"` fed to [`Namespace::parse`] — is rejected at construction
11//! time rather than producing a silently-tenantless instance whose
12//! row-level filter then collapses every tenant onto one key prefix.
13
14use entelix_core::{Error, Result, TenantId};
15use serde::{Deserialize, Serialize};
16
17/// Hierarchical key prefix for memory operations.
18///
19/// `tenant_id` segments out per-customer data. `scope` adds nested
20/// dimensions — typically `[agent_id, conversation_id]` for chat-style
21/// agents.
22#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
23pub struct Namespace {
24    tenant_id: TenantId,
25    scope: Vec<String>,
26}
27
28impl Namespace {
29    /// Build a namespace bound to `tenant_id`. The mandatory argument
30    /// is the F2 mitigation in code form — there is no other way to
31    /// obtain a `Namespace`. The [`TenantId`] argument has already
32    /// passed its validating constructor, so cross-tenant collapse
33    /// (`":scope"` instead of `"acme:scope"`) is structurally
34    /// impossible at this surface.
35    pub const fn new(tenant_id: TenantId) -> Self {
36        Self {
37            tenant_id,
38            scope: Vec::new(),
39        }
40    }
41
42    /// Append one scope segment. Builder-style.
43    #[must_use]
44    pub fn with_scope(mut self, segment: impl Into<String>) -> Self {
45        self.scope.push(segment.into());
46        self
47    }
48
49    /// Borrow the tenant identifier.
50    pub const fn tenant_id(&self) -> &TenantId {
51        &self.tenant_id
52    }
53
54    /// Borrow the scope segments in registration order.
55    pub fn scope(&self) -> &[String] {
56        &self.scope
57    }
58
59    /// Render the namespace as a flat `:`-separated key, useful for
60    /// backends that take a single string per row. Segments
61    /// containing `:` or `\` are escaped (`\:` and `\\`) so two
62    /// distinct namespaces can never collide on the rendered key.
63    pub fn render(&self) -> String {
64        let tenant_id = self.tenant_id.as_str();
65        let mut out = String::with_capacity(
66            tenant_id.len() + self.scope.iter().map(|s| s.len() + 1).sum::<usize>(),
67        );
68        push_escaped(&mut out, tenant_id);
69        for s in &self.scope {
70            out.push(':');
71            push_escaped(&mut out, s);
72        }
73        out
74    }
75
76    /// Inverse of [`Self::render`]. Decodes a flat `:`-separated
77    /// key back into a typed [`Namespace`], honouring the same
78    /// escape rules (`\:` for `:` inside a segment, `\\` for `\`).
79    /// Round-trip property: `Namespace::parse(&ns.render()) ==
80    /// Ok(ns.clone())` for every well-formed namespace.
81    ///
82    /// The motivating consumer is the audit channel (invariant
83    /// #18): `GraphEvent::MemoryRecall::namespace_key` carries
84    /// rendered keys, and operators replaying the log recover the
85    /// typed scope (tenant boundary, agent / conversation
86    /// dimensions) by parsing.
87    ///
88    /// Rejects:
89    /// - leading `:` (e.g. `":scope"`) — empty tenant component,
90    ///   surfaces as [`Error::InvalidRequest`] via the
91    ///   [`TenantId::try_from`] validator. Invariant 11 — a
92    ///   tenantless `Namespace` would silently collapse every
93    ///   tenant onto a single rendered key prefix.
94    /// - trailing lone `\` (incomplete escape) — surfaces as
95    ///   [`Error::InvalidRequest`].
96    /// - `\<x>` for `x` other than `:` or `\` — unknown escape,
97    ///   same error variant. Round-tripping a namespace that
98    ///   never contained `:` or `\` cannot produce these inputs;
99    ///   they only arise from hand-crafted (invalid) keys.
100    pub fn parse(rendered: &str) -> Result<Self> {
101        let mut segments: Vec<String> = Vec::new();
102        let mut current = String::with_capacity(rendered.len());
103        let mut chars = rendered.chars();
104        while let Some(ch) = chars.next() {
105            match ch {
106                ':' => {
107                    segments.push(std::mem::take(&mut current));
108                }
109                '\\' => match chars.next() {
110                    Some(escaped @ (':' | '\\')) => current.push(escaped),
111                    Some(other) => {
112                        return Err(Error::invalid_request(format!(
113                            "Namespace::parse: unknown escape \\{other}"
114                        )));
115                    }
116                    None => {
117                        return Err(Error::invalid_request(
118                            "Namespace::parse: trailing backslash",
119                        ));
120                    }
121                },
122                other => current.push(other),
123            }
124        }
125        segments.push(current);
126        // `segments` is non-empty because the loop always pushes a
127        // final segment after the last separator (or after exiting
128        // the loop with no separators at all on an empty string,
129        // in which case `segments == [""]`). The first segment is
130        // the tenant component — empty surfaces as
131        // `Error::InvalidRequest` via `TenantId::try_from`.
132        let tenant_id = TenantId::try_from(segments.remove(0))?;
133        Ok(Self {
134            tenant_id,
135            scope: segments,
136        })
137    }
138}
139
140fn push_escaped(out: &mut String, segment: &str) {
141    if !segment.contains([':', '\\']) {
142        out.push_str(segment);
143        return;
144    }
145    for ch in segment.chars() {
146        match ch {
147            ':' | '\\' => {
148                out.push('\\');
149                out.push(ch);
150            }
151            other => out.push(other),
152        }
153    }
154}
155
156/// Hierarchical prefix used by [`crate::Store::list_namespaces`].
157///
158/// Matches every [`Namespace`] whose tenant matches `tenant_id` and
159/// whose scope segments start with the prefix's segments. An empty
160/// `scope` matches every namespace under `tenant_id`. The shape
161/// mirrors `Namespace` so callers compose the two consistently —
162/// the tenant boundary is enforced for namespace listings just like
163/// it is for individual entries (Invariant 11 / F2).
164#[derive(Clone, Debug, Eq, Hash, PartialEq)]
165pub struct NamespacePrefix {
166    tenant_id: TenantId,
167    scope: Vec<String>,
168}
169
170impl NamespacePrefix {
171    /// Build a prefix matching every namespace under `tenant_id`.
172    /// Append scope segments via [`Self::with_scope`] to narrow.
173    /// The [`TenantId`] argument has already passed its validating
174    /// constructor — invariant 11 cannot be bypassed via the
175    /// listing surface.
176    #[must_use]
177    pub const fn new(tenant_id: TenantId) -> Self {
178        Self {
179            tenant_id,
180            scope: Vec::new(),
181        }
182    }
183
184    /// Append one scope segment. Builder-style.
185    #[must_use]
186    pub fn with_scope(mut self, segment: impl Into<String>) -> Self {
187        self.scope.push(segment.into());
188        self
189    }
190
191    /// Borrow the tenant identifier.
192    #[must_use]
193    pub const fn tenant_id(&self) -> &TenantId {
194        &self.tenant_id
195    }
196
197    /// Borrow the scope segments in registration order.
198    #[must_use]
199    pub fn scope(&self) -> &[String] {
200        &self.scope
201    }
202
203    /// True when `ns` falls under this prefix.
204    #[must_use]
205    pub fn matches(&self, ns: &Namespace) -> bool {
206        ns.tenant_id() == &self.tenant_id && ns.scope().starts_with(&self.scope)
207    }
208}
209
210impl From<&Namespace> for NamespacePrefix {
211    fn from(ns: &Namespace) -> Self {
212        Self {
213            tenant_id: ns.tenant_id().clone(),
214            scope: ns.scope().to_vec(),
215        }
216    }
217}
218
219#[cfg(test)]
220#[allow(clippy::unwrap_used)]
221mod tests {
222    use super::*;
223
224    fn t(s: &str) -> TenantId {
225        TenantId::new(s)
226    }
227
228    #[test]
229    fn prefix_matches_subnamespace_and_rejects_other_tenant() {
230        let parent = NamespacePrefix::new(t("acme")).with_scope("agent-a");
231        assert!(parent.matches(&Namespace::new(t("acme")).with_scope("agent-a")));
232        assert!(
233            parent.matches(
234                &Namespace::new(t("acme"))
235                    .with_scope("agent-a")
236                    .with_scope("conv-7")
237            )
238        );
239        assert!(!parent.matches(&Namespace::new(t("acme")).with_scope("agent-b")));
240        assert!(!parent.matches(&Namespace::new(t("other-tenant")).with_scope("agent-a")));
241    }
242
243    #[test]
244    fn prefix_with_empty_scope_matches_every_namespace_under_tenant() {
245        let p = NamespacePrefix::new(t("acme"));
246        assert!(p.matches(&Namespace::new(t("acme"))));
247        assert!(p.matches(&Namespace::new(t("acme")).with_scope("any")));
248        assert!(!p.matches(&Namespace::new(t("other"))));
249    }
250
251    #[test]
252    fn from_namespace_round_trips() {
253        let ns = Namespace::new(t("acme"))
254            .with_scope("agent-a")
255            .with_scope("conv-1");
256        let prefix = NamespacePrefix::from(&ns);
257        assert_eq!(prefix.tenant_id().as_str(), "acme");
258        assert_eq!(prefix.scope(), &["agent-a".to_owned(), "conv-1".to_owned()]);
259        assert!(prefix.matches(&ns));
260    }
261
262    fn round_trip(ns: &Namespace) {
263        let rendered = ns.render();
264        let parsed = Namespace::parse(&rendered).unwrap();
265        assert_eq!(&parsed, ns, "round-trip failed for {rendered:?}");
266    }
267
268    #[test]
269    fn parse_round_trips_simple_namespace() {
270        round_trip(&Namespace::new(t("acme")));
271        round_trip(&Namespace::new(t("acme")).with_scope("agent-a"));
272        round_trip(
273            &Namespace::new(t("acme"))
274                .with_scope("agent-a")
275                .with_scope("conv-1"),
276        );
277    }
278
279    #[test]
280    fn parse_round_trips_empty_scope_segments() {
281        // Empty scope segments are valid (operators sometimes use
282        // them as an explicit "no further dimension" marker); empty
283        // tenant_id is not (invariant 11 — see
284        // `parse_rejects_leading_colon_for_empty_tenant`).
285        round_trip(&Namespace::new(t("acme")).with_scope(""));
286        round_trip(&Namespace::new(t("acme")).with_scope("a").with_scope(""));
287    }
288
289    #[test]
290    fn parse_round_trips_segments_with_colon() {
291        round_trip(&Namespace::new(t("a:b")).with_scope("c:d"));
292        round_trip(&Namespace::new(t("acme")).with_scope("k8s:pod:foo"));
293    }
294
295    #[test]
296    fn parse_round_trips_segments_with_backslash() {
297        round_trip(&Namespace::new(t("a\\b")).with_scope("c\\d"));
298        round_trip(&Namespace::new(t("acme")).with_scope("\\\\\\:"));
299    }
300
301    #[test]
302    fn parse_extracts_tenant_and_scope_from_simple_input() {
303        let ns = Namespace::parse("acme:agent-a:conv-1").unwrap();
304        assert_eq!(ns.tenant_id().as_str(), "acme");
305        assert_eq!(ns.scope(), &["agent-a".to_owned(), "conv-1".to_owned()]);
306    }
307
308    #[test]
309    fn parse_decodes_escapes() {
310        let ns = Namespace::parse("a\\:b:c\\\\d").unwrap();
311        assert_eq!(ns.tenant_id().as_str(), "a:b");
312        assert_eq!(ns.scope(), &["c\\d".to_owned()]);
313    }
314
315    #[test]
316    fn parse_rejects_trailing_backslash() {
317        let err = Namespace::parse("acme\\").unwrap_err();
318        assert!(format!("{err}").contains("trailing backslash"));
319    }
320
321    #[test]
322    fn parse_rejects_unknown_escape() {
323        let err = Namespace::parse("acme\\x").unwrap_err();
324        let msg = format!("{err}");
325        assert!(msg.contains("unknown escape"), "got {msg}");
326    }
327
328    #[test]
329    fn parse_rejects_leading_colon_for_empty_tenant() {
330        // Invariant 11 — `":scope"` would silently collapse every
331        // tenant onto a single rendered key prefix. The
332        // `TenantId::try_from` validator surfaces it as
333        // `Error::InvalidRequest`.
334        let err = Namespace::parse(":scope").unwrap_err();
335        let msg = format!("{err}");
336        assert!(matches!(err, Error::InvalidRequest(_)), "got {err:?}");
337        assert!(msg.contains("tenant_id must be non-empty"), "got {msg}");
338    }
339
340    #[test]
341    fn parse_rejects_empty_string_for_empty_tenant() {
342        // Edge — an entirely empty rendered key produces a single
343        // empty segment that maps to an empty tenant. Same
344        // mitigation, same error.
345        let err = Namespace::parse("").unwrap_err();
346        assert!(matches!(err, Error::InvalidRequest(_)), "got {err:?}");
347    }
348
349    #[test]
350    fn deserialize_rejects_empty_tenant_in_wire_payload() {
351        // Invariant 11 — a `Namespace` materialised from an
352        // untrusted JSON payload runs the validating constructor;
353        // an empty tenant cannot be hydrated.
354        let err = serde_json::from_str::<Namespace>(r#"{"tenant_id":"","scope":["agent-a"]}"#)
355            .unwrap_err();
356        assert!(
357            err.to_string().contains("tenant_id must be non-empty"),
358            "got {err}"
359        );
360    }
361}