Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions crates/tinymemory-api/src/namespace/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,36 @@ impl Reach {
}
}

/// Whether `self` reads nothing `outer` does not: every namespace `self`
/// admits, `outer` admits too.
Comment thread
senamakel marked this conversation as resolved.
///
/// 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`.
Expand Down
48 changes: 48 additions & 0 deletions crates/tinymemory-api/src/namespace/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
11 changes: 11 additions & 0 deletions docs/architecture/namespaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/memory-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Reach>` 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<Reach>` 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.
Expand Down
Loading