diff --git a/crates/tinymemory-api/src/namespace/mod.rs b/crates/tinymemory-api/src/namespace/mod.rs index 29ece082..d06feb2b 100644 --- a/crates/tinymemory-api/src/namespace/mod.rs +++ b/crates/tinymemory-api/src/namespace/mod.rs @@ -401,6 +401,36 @@ impl Reach { } } + /// Whether `self` reads nothing `outer` does not: every namespace `self` + /// admits, `outer` admits too. + /// + /// This is how a host confines a caller-supplied reach to the one it + /// allows (an agent's or an identity's subtree): a reach `within` the + /// allowed one may be used as given; any other would widen it. `at` must + /// be in `outer`; with `inherit`, so must every ancestor of `at` (a reach + /// inheriting above a subtree's top is not within it); with + /// `descendants`, `outer` must read descendants too and `at` must lie at + /// or below `outer.at` (the descendants of an ancestor of `outer.at` + /// include its siblings). A reach at the deepest legal node reads no + /// descendants (none can exist), so it counts as exact. + #[must_use] + pub fn within(&self, outer: &Self) -> bool { + if !outer.admits(&self.at) { + return false; + } + if self.inherit + && !self + .at + .ancestors_and_self() + .iter() + .all(|node| outer.admits(node)) + { + return false; + } + let reads_below = self.descendants && self.at.depth() < MAX_DEPTH; + !reads_below || (outer.descendants && self.at.is_within(&outer.at)) + } + /// The nodes read exactly, root first: `at` and, when `inherit`, its /// ancestors. Descendants are not enumerable here; an engine reads them /// as one subtree below `at`. diff --git a/crates/tinymemory-api/src/namespace/mod_tests.rs b/crates/tinymemory-api/src/namespace/mod_tests.rs index 8e1787fa..0839931c 100644 --- a/crates/tinymemory-api/src/namespace/mod_tests.rs +++ b/crates/tinymemory-api/src/namespace/mod_tests.rs @@ -191,3 +191,51 @@ fn refuses_a_child_past_the_depth_limit() { .unwrap_err(); assert!(matches!(error, Error::InvalidRequest(_)), "{error}"); } + +#[test] +fn a_reach_inside_a_subtree_is_within_it() { + let team = Reach::subtree(ns("team:acme")); + assert!(team.within(&team)); + assert!(Reach::subtree(ns("team:acme/agent:writer")).within(&team)); + assert!(Reach::exact(ns("team:acme/agent:writer")).within(&team)); + assert!(Reach::exact(ns("team:acme")).within(&team)); + // Everything is within the root's subtree, except a service sandbox. + let all = Reach::subtree(Namespace::ROOT); + assert!(Reach::of(ns("team:acme/agent:writer")).within(&all)); + assert!(Reach::subtree(ns("team:acme")).within(&all)); + assert!(!Reach::exact(ns("service:flows")).within(&all)); +} + +#[test] +fn a_reach_leaving_a_subtree_is_not_within_it() { + let team = Reach::subtree(ns("team:acme")); + // A sibling, the root, an ancestor's subtree. + assert!(!Reach::subtree(ns("team:other")).within(&team)); + assert!(!Reach::exact(Namespace::ROOT).within(&team)); + assert!(!Reach::subtree(Namespace::ROOT).within(&team)); + // Inheriting reads the root above the subtree's top. + assert!(!Reach::of(ns("team:acme/agent:writer")).within(&team)); + // A service sandbox below the subtree is not read by it. + assert!(!Reach::exact(ns("team:acme/service:flows")).within(&team)); +} + +#[test] +fn descendants_need_descendants_and_a_node_at_or_below() { + let agent = Reach::of(ns("team:acme/agent:writer")); + assert!(Reach::exact(ns("team:acme")).within(&agent)); + assert!(Reach::of(ns("team:acme")).within(&agent)); + // The team's subtree holds every other member's memory. + assert!(!Reach::subtree(ns("team:acme")).within(&agent)); + assert!(!Reach::subtree(ns("team:acme/agent:writer")).within(&agent)); + assert!(!Reach::exact(ns("team:acme/agent:editor")).within(&agent)); +} + +#[test] +fn a_subtree_at_the_depth_limit_is_a_singleton() { + let deep = ns(&["agent:a"; MAX_DEPTH].join("/")); + // Nothing can exist below the deepest node, so its subtree is exact. + assert!(Reach::subtree(deep.clone()).within(&Reach::exact(deep.clone()))); + // Still confined: a different node of that depth is outside. + let other = ns(&["agent:b"; MAX_DEPTH].join("/")); + assert!(!Reach::subtree(other).within(&Reach::exact(deep))); +} diff --git a/docs/architecture/namespaces.md b/docs/architecture/namespaces.md index 8db93722..de07b330 100644 --- a/docs/architecture/namespaces.md +++ b/docs/architecture/namespaces.md @@ -138,6 +138,17 @@ or `GetRequest::reach` left `None`) reads as `Reach::subtree(Namespace::ROOT)` | `Reach::subtree(at)` | no | yes | `at` and everything below it, no ancestors | | `Reach::default()` | yes | no | `Reach::of(root)`: **only the root** | +`Reach::within(outer)` is the confinement test a host applies to a +caller-supplied reach: true when every namespace `self` admits, `outer` +admits too (so `self` may be used as given; any other would widen `outer`). +`at` must be in `outer`; with `inherit`, every ancestor of `at` must be in +`outer` too (inheriting above a subtree's top escapes it); with +`descendants`, `outer` must read descendants and `at` must lie at or below +`outer.at`, because the descendants of an ancestor include its siblings. A +`service:` sandbox below `outer.at` stays out of `outer`'s descendants, as in +`admits`. A reach at the deepest legal node (depth 8) has no descendants, so +`descendants` there counts as exact. + `Reach::nodes()` lists the nodes read exactly, root first (`at` and, when `inherit`, its ancestors). Descendants cannot be enumerated from the reach; an engine reads them as one subtree below `at`. diff --git a/docs/specs/memory-v2.md b/docs/specs/memory-v2.md index 43f7255e..2c85cd08 100644 --- a/docs/specs/memory-v2.md +++ b/docs/specs/memory-v2.md @@ -154,7 +154,7 @@ listing can be ordered by it. An item's id is its `StoreItem::fingerprint()`. Memory is a tree of nodes (`Namespace`, written `team:acme/agent:writer`; the empty path is the root, written `root`). The root holds what every agent shares; each agent, sub-agent (nested under its spawner), team, user, workspace, project, knowledge source or service (one automation, such as a workflow) has its own node (`SegmentKind`, non-exhaustive). Segment ids are `[A-Za-z0-9_-]{1,128}`; `Segment::sanitized` maps any host id onto that charset without collisions; depth is at most 8. - **Placement.** `MemoryMeta.namespace` (default root, omitted on the wire when root) puts an item at one node, and is part of its fingerprint: the same text at two nodes is two items. Old envelopes read as root. -- **Reach.** `MetaFilter.reach: Option` confines every filtered read (recall, fetch, list, explore, forget by filter). `Reach { at, inherit, descendants }` admits `at`, its ancestors when `inherit` (the default, so an agent reads what its team and the root share), and everything below it when `descendants`. A sibling is never admitted, and a descendant read never enters a `service:` sandbox below `at`. `None` reads as `Reach::subtree(Namespace::ROOT)`: every node except a sandbox. +- **Reach.** `MetaFilter.reach: Option` confines every filtered read (recall, fetch, list, explore, forget by filter). `Reach { at, inherit, descendants }` admits `at`, its ancestors when `inherit` (the default, so an agent reads what its team and the root share), and everything below it when `descendants`. A sibling is never admitted, and a descendant read never enters a `service:` sandbox below `at`. `None` reads as `Reach::subtree(Namespace::ROOT)`: every node except a sandbox. `Reach::within(outer)` tests confinement: it is true when every namespace the reach admits, `outer` admits too (`at` in `outer`; every ancestor in `outer` when `inherit`; `outer.descendants` and `at` at or below `outer.at` when `descendants`, except at maximum depth where no descendant exists), so a host can accept a caller's reach only when it does not widen the one it allows. - **Get and forget by id.** `GetRequest.reach` leaves out ids beyond it. `ForgetTarget::Ids` is not scoped; a confined caller reads the ids with `get` and its reach first. - **Explore.** `Facet::Namespace` groups by node; narrowing a value reads exactly that node. - **Context.** `ContextSpec.reach` compiles a document from one node's reach.