feat(memory): implement memory scoping with hierarchy and access control (#93)

Add scope management system for hierarchical memory access:
- ScopeManager with hierarchy: Global → Project → Agent Type → Agent Instance → Session
- ScopePolicy for access control (read, write, inherit permissions)
- ScopeResolver for resolving queries across scope hierarchies with inheritance
- ScopeFilter for filtering scopes by type, project, or agent
- Access control enforcement with parent scope visibility
- Deduplication support during resolution across scopes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-05 02:39:22 +01:00
parent b818f17418
commit 48ecb40f18
6 changed files with 1892 additions and 1 deletions

View File

@@ -1,3 +1,4 @@
# app/services/memory/scoping/__init__.py
"""
Memory Scoping
@@ -5,4 +6,28 @@ Hierarchical scoping for memory with inheritance:
Global -> Project -> Agent Type -> Agent Instance -> Session
"""
# Will be populated in #93
from .resolver import (
ResolutionOptions,
ResolutionResult,
ScopeFilter,
ScopeResolver,
get_scope_resolver,
)
from .scope import (
ScopeInfo,
ScopeManager,
ScopePolicy,
get_scope_manager,
)
__all__ = [
"ResolutionOptions",
"ResolutionResult",
"ScopeFilter",
"ScopeInfo",
"ScopeManager",
"ScopePolicy",
"ScopeResolver",
"get_scope_manager",
"get_scope_resolver",
]

View File

@@ -0,0 +1,390 @@
# app/services/memory/scoping/resolver.py
"""
Scope Resolution.
Provides utilities for resolving memory queries across scope hierarchies,
implementing inheritance and aggregation of memories from parent scopes.
"""
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, TypeVar
from app.services.memory.types import ScopeContext, ScopeLevel
from .scope import ScopeManager, get_scope_manager
logger = logging.getLogger(__name__)
T = TypeVar("T")
@dataclass
class ResolutionResult[T]:
"""Result of a scope resolution."""
items: list[T]
sources: list[ScopeContext]
total_from_each: dict[str, int] = field(default_factory=dict)
inherited_count: int = 0
own_count: int = 0
@property
def total_count(self) -> int:
"""Get total items from all sources."""
return len(self.items)
@dataclass
class ResolutionOptions:
"""Options for scope resolution."""
include_inherited: bool = True
max_inheritance_depth: int = 5
limit_per_scope: int = 100
total_limit: int = 500
deduplicate: bool = True
deduplicate_key: str | None = None # Field to use for deduplication
class ScopeResolver:
"""
Resolves memory queries across scope hierarchies.
Features:
- Traverse scope hierarchy for inherited memories
- Aggregate results from multiple scope levels
- Apply access control policies
- Support deduplication across scopes
"""
def __init__(
self,
manager: ScopeManager | None = None,
) -> None:
"""
Initialize the resolver.
Args:
manager: Scope manager to use (defaults to singleton)
"""
self._manager = manager or get_scope_manager()
def resolve(
self,
scope: ScopeContext,
fetcher: Callable[[ScopeContext, int], list[T]],
options: ResolutionOptions | None = None,
) -> ResolutionResult[T]:
"""
Resolve memories for a scope, including inherited memories.
Args:
scope: Starting scope
fetcher: Function to fetch items for a scope (scope, limit) -> items
options: Resolution options
Returns:
Resolution result with items from all scopes
"""
opts = options or ResolutionOptions()
all_items: list[T] = []
sources: list[ScopeContext] = []
counts: dict[str, int] = {}
seen_keys: set[Any] = set()
# Collect scopes to query (starting from current, going up to ancestors)
scopes_to_query = self._collect_queryable_scopes(
scope=scope,
max_depth=opts.max_inheritance_depth if opts.include_inherited else 0,
)
own_count = 0
inherited_count = 0
remaining_limit = opts.total_limit
for i, query_scope in enumerate(scopes_to_query):
if remaining_limit <= 0:
break
# Check access policy
policy = self._manager.get_policy(query_scope)
if not policy.allows_read():
continue
if i > 0 and not policy.allows_inherit():
continue
# Fetch items for this scope
scope_limit = min(opts.limit_per_scope, remaining_limit)
items = fetcher(query_scope, scope_limit)
# Apply deduplication
if opts.deduplicate and opts.deduplicate_key:
items = self._deduplicate(items, opts.deduplicate_key, seen_keys)
if items:
all_items.extend(items)
sources.append(query_scope)
key = f"{query_scope.scope_type.value}:{query_scope.scope_id}"
counts[key] = len(items)
if i == 0:
own_count = len(items)
else:
inherited_count += len(items)
remaining_limit -= len(items)
logger.debug(
f"Resolved {len(all_items)} items from {len(sources)} scopes "
f"(own={own_count}, inherited={inherited_count})"
)
return ResolutionResult(
items=all_items[: opts.total_limit],
sources=sources,
total_from_each=counts,
own_count=own_count,
inherited_count=inherited_count,
)
def _collect_queryable_scopes(
self,
scope: ScopeContext,
max_depth: int,
) -> list[ScopeContext]:
"""Collect scopes to query, from current to ancestors."""
scopes: list[ScopeContext] = [scope]
if max_depth <= 0:
return scopes
current = scope.parent
depth = 0
while current is not None and depth < max_depth:
scopes.append(current)
current = current.parent
depth += 1
return scopes
def _deduplicate(
self,
items: list[T],
key_field: str,
seen_keys: set[Any],
) -> list[T]:
"""Remove duplicate items based on a key field."""
unique: list[T] = []
for item in items:
key = getattr(item, key_field, None)
if key is None:
# If no key, include the item
unique.append(item)
elif key not in seen_keys:
seen_keys.add(key)
unique.append(item)
return unique
def get_visible_scopes(
self,
scope: ScopeContext,
) -> list[ScopeContext]:
"""
Get all scopes visible from a given scope.
A scope can see itself and all its ancestors (if inheritance allowed).
Args:
scope: Starting scope
Returns:
List of visible scopes (from most specific to most general)
"""
visible = [scope]
current = scope.parent
while current is not None:
policy = self._manager.get_policy(current)
if policy.allows_inherit():
visible.append(current)
else:
break # Stop at first non-inheritable scope
current = current.parent
return visible
def find_write_scope(
self,
target_level: ScopeLevel,
scope: ScopeContext,
) -> ScopeContext | None:
"""
Find the appropriate scope for writing at a target level.
Walks up the hierarchy to find a scope at the target level
that allows writing.
Args:
target_level: Desired scope level
scope: Starting scope
Returns:
Scope to write to, or None if not found/not allowed
"""
# First check if current scope is at target level
if scope.scope_type == target_level:
policy = self._manager.get_policy(scope)
return scope if policy.allows_write() else None
# Check ancestors
current = scope.parent
while current is not None:
if current.scope_type == target_level:
policy = self._manager.get_policy(current)
return current if policy.allows_write() else None
current = current.parent
return None
def resolve_scope_from_memory(
self,
memory_type: str,
project_id: str | None = None,
agent_type_id: str | None = None,
agent_instance_id: str | None = None,
session_id: str | None = None,
) -> tuple[ScopeContext, ScopeLevel]:
"""
Resolve the appropriate scope for a memory operation.
Different memory types have different scope requirements:
- working: Session or Agent Instance
- episodic: Agent Instance or Project
- semantic: Project or Global
- procedural: Agent Type or Project
Args:
memory_type: Type of memory
project_id: Project ID
agent_type_id: Agent type ID
agent_instance_id: Agent instance ID
session_id: Session ID
Returns:
Tuple of (scope context, recommended level)
"""
# Build full scope chain
scope = self._manager.create_scope_from_ids(
project_id=project_id if project_id else None, # type: ignore[arg-type]
agent_type_id=agent_type_id if agent_type_id else None, # type: ignore[arg-type]
agent_instance_id=agent_instance_id if agent_instance_id else None, # type: ignore[arg-type]
session_id=session_id,
)
# Determine recommended level based on memory type
recommended = self._get_recommended_level(memory_type)
return scope, recommended
def _get_recommended_level(self, memory_type: str) -> ScopeLevel:
"""Get recommended scope level for a memory type."""
recommendations = {
"working": ScopeLevel.SESSION,
"episodic": ScopeLevel.AGENT_INSTANCE,
"semantic": ScopeLevel.PROJECT,
"procedural": ScopeLevel.AGENT_TYPE,
}
return recommendations.get(memory_type, ScopeLevel.PROJECT)
def validate_write_access(
self,
scope: ScopeContext,
memory_type: str,
) -> bool:
"""
Validate that writing is allowed for the given scope and memory type.
Args:
scope: Scope to validate
memory_type: Type of memory to write
Returns:
True if write is allowed
"""
policy = self._manager.get_policy(scope)
if not policy.allows_write():
return False
if not policy.allows_memory_type(memory_type):
return False
return True
def get_scope_chain(
self,
scope: ScopeContext,
) -> list[tuple[ScopeLevel, str]]:
"""
Get the scope chain as a list of (level, id) tuples.
Args:
scope: Scope to get chain for
Returns:
List of (level, id) tuples from root to leaf
"""
chain: list[tuple[ScopeLevel, str]] = []
# Get full hierarchy
hierarchy = scope.get_hierarchy()
for ctx in hierarchy:
chain.append((ctx.scope_type, ctx.scope_id))
return chain
@dataclass
class ScopeFilter:
"""Filter for querying across scopes."""
scope_types: list[ScopeLevel] | None = None
project_ids: list[str] | None = None
agent_type_ids: list[str] | None = None
include_global: bool = True
def matches(self, scope: ScopeContext) -> bool:
"""Check if a scope matches this filter."""
if self.scope_types and scope.scope_type not in self.scope_types:
return False
if scope.scope_type == ScopeLevel.GLOBAL:
return self.include_global
if scope.scope_type == ScopeLevel.PROJECT:
if self.project_ids and scope.scope_id not in self.project_ids:
return False
if scope.scope_type == ScopeLevel.AGENT_TYPE:
if self.agent_type_ids and scope.scope_id not in self.agent_type_ids:
return False
return True
# Singleton resolver instance
_resolver: ScopeResolver | None = None
def get_scope_resolver() -> ScopeResolver:
"""Get the singleton scope resolver instance."""
global _resolver
if _resolver is None:
_resolver = ScopeResolver()
return _resolver

View File

@@ -0,0 +1,460 @@
# app/services/memory/scoping/scope.py
"""
Scope Management.
Provides utilities for managing memory scopes with hierarchical inheritance:
Global -> Project -> Agent Type -> Agent Instance -> Session
"""
import logging
from dataclasses import dataclass, field
from typing import Any, ClassVar
from uuid import UUID
from app.services.memory.types import ScopeContext, ScopeLevel
logger = logging.getLogger(__name__)
@dataclass
class ScopePolicy:
"""Access control policy for a scope."""
scope_type: ScopeLevel
scope_id: str
can_read: bool = True
can_write: bool = True
can_inherit: bool = True
allowed_memory_types: list[str] = field(default_factory=lambda: ["all"])
metadata: dict[str, Any] = field(default_factory=dict)
def allows_read(self) -> bool:
"""Check if reading is allowed."""
return self.can_read
def allows_write(self) -> bool:
"""Check if writing is allowed."""
return self.can_write
def allows_inherit(self) -> bool:
"""Check if inheritance from parent is allowed."""
return self.can_inherit
def allows_memory_type(self, memory_type: str) -> bool:
"""Check if a specific memory type is allowed."""
return (
"all" in self.allowed_memory_types
or memory_type in self.allowed_memory_types
)
@dataclass
class ScopeInfo:
"""Information about a scope including its hierarchy."""
context: ScopeContext
policy: ScopePolicy
parent_info: "ScopeInfo | None" = None
child_count: int = 0
memory_count: int = 0
@property
def depth(self) -> int:
"""Get the depth of this scope in the hierarchy."""
count = 0
current = self.parent_info
while current is not None:
count += 1
current = current.parent_info
return count
class ScopeManager:
"""
Manages memory scopes and their hierarchies.
Provides:
- Scope creation and validation
- Hierarchy management
- Access control policy management
- Scope inheritance rules
"""
# Order of scope levels from root to leaf
SCOPE_ORDER: ClassVar[list[ScopeLevel]] = [
ScopeLevel.GLOBAL,
ScopeLevel.PROJECT,
ScopeLevel.AGENT_TYPE,
ScopeLevel.AGENT_INSTANCE,
ScopeLevel.SESSION,
]
def __init__(self) -> None:
"""Initialize the scope manager."""
# In-memory policy cache (would be backed by database in production)
self._policies: dict[str, ScopePolicy] = {}
self._default_policies = self._create_default_policies()
def _create_default_policies(self) -> dict[ScopeLevel, ScopePolicy]:
"""Create default policies for each scope level."""
return {
ScopeLevel.GLOBAL: ScopePolicy(
scope_type=ScopeLevel.GLOBAL,
scope_id="global",
can_read=True,
can_write=False, # Global writes require special permission
can_inherit=True,
),
ScopeLevel.PROJECT: ScopePolicy(
scope_type=ScopeLevel.PROJECT,
scope_id="default",
can_read=True,
can_write=True,
can_inherit=True,
),
ScopeLevel.AGENT_TYPE: ScopePolicy(
scope_type=ScopeLevel.AGENT_TYPE,
scope_id="default",
can_read=True,
can_write=True,
can_inherit=True,
),
ScopeLevel.AGENT_INSTANCE: ScopePolicy(
scope_type=ScopeLevel.AGENT_INSTANCE,
scope_id="default",
can_read=True,
can_write=True,
can_inherit=True,
),
ScopeLevel.SESSION: ScopePolicy(
scope_type=ScopeLevel.SESSION,
scope_id="default",
can_read=True,
can_write=True,
can_inherit=True,
allowed_memory_types=["working"], # Sessions only allow working memory
),
}
def create_scope(
self,
scope_type: ScopeLevel,
scope_id: str,
parent: ScopeContext | None = None,
) -> ScopeContext:
"""
Create a new scope context.
Args:
scope_type: Level of the scope
scope_id: Unique identifier within the level
parent: Optional parent scope
Returns:
Created scope context
Raises:
ValueError: If scope hierarchy is invalid
"""
# Validate hierarchy
if parent is not None:
self._validate_parent_child(parent.scope_type, scope_type)
# For non-global scopes without parent, auto-create parent chain
if parent is None and scope_type != ScopeLevel.GLOBAL:
parent = self._create_parent_chain(scope_type, scope_id)
context = ScopeContext(
scope_type=scope_type,
scope_id=scope_id,
parent=parent,
)
logger.debug(f"Created scope: {scope_type.value}:{scope_id}")
return context
def _validate_parent_child(
self,
parent_type: ScopeLevel,
child_type: ScopeLevel,
) -> None:
"""Validate that parent-child relationship is valid."""
parent_idx = self.SCOPE_ORDER.index(parent_type)
child_idx = self.SCOPE_ORDER.index(child_type)
if child_idx <= parent_idx:
raise ValueError(
f"Invalid scope hierarchy: {child_type.value} cannot be child of {parent_type.value}"
)
# Allow skipping levels (e.g., PROJECT -> SESSION is valid)
# This enables flexible scope structures
def _create_parent_chain(
self,
target_type: ScopeLevel,
scope_id: str,
) -> ScopeContext:
"""Create parent scope chain up to target type."""
target_idx = self.SCOPE_ORDER.index(target_type)
# Start from global and build chain
current: ScopeContext | None = None
for i in range(target_idx):
level = self.SCOPE_ORDER[i]
if level == ScopeLevel.GLOBAL:
level_id = "global"
else:
# Use a default ID for intermediate levels
level_id = f"default_{level.value}"
current = ScopeContext(
scope_type=level,
scope_id=level_id,
parent=current,
)
return current # type: ignore[return-value]
def create_scope_from_ids(
self,
project_id: UUID | None = None,
agent_type_id: UUID | None = None,
agent_instance_id: UUID | None = None,
session_id: str | None = None,
) -> ScopeContext:
"""
Create a scope context from individual IDs.
Automatically determines the most specific scope level
based on provided IDs.
Args:
project_id: Project UUID
agent_type_id: Agent type UUID
agent_instance_id: Agent instance UUID
session_id: Session identifier
Returns:
Scope context for the most specific level
"""
# Build scope chain from most general to most specific
current: ScopeContext = ScopeContext(
scope_type=ScopeLevel.GLOBAL,
scope_id="global",
parent=None,
)
if project_id is not None:
current = ScopeContext(
scope_type=ScopeLevel.PROJECT,
scope_id=str(project_id),
parent=current,
)
if agent_type_id is not None:
current = ScopeContext(
scope_type=ScopeLevel.AGENT_TYPE,
scope_id=str(agent_type_id),
parent=current,
)
if agent_instance_id is not None:
current = ScopeContext(
scope_type=ScopeLevel.AGENT_INSTANCE,
scope_id=str(agent_instance_id),
parent=current,
)
if session_id is not None:
current = ScopeContext(
scope_type=ScopeLevel.SESSION,
scope_id=session_id,
parent=current,
)
return current
def get_policy(
self,
scope: ScopeContext,
) -> ScopePolicy:
"""
Get the access policy for a scope.
Args:
scope: Scope to get policy for
Returns:
Policy for the scope
"""
key = self._scope_key(scope)
if key in self._policies:
return self._policies[key]
# Return default policy for the scope level
return self._default_policies.get(
scope.scope_type,
ScopePolicy(
scope_type=scope.scope_type,
scope_id=scope.scope_id,
),
)
def set_policy(
self,
scope: ScopeContext,
policy: ScopePolicy,
) -> None:
"""
Set the access policy for a scope.
Args:
scope: Scope to set policy for
policy: Policy to apply
"""
key = self._scope_key(scope)
self._policies[key] = policy
logger.info(f"Set policy for scope {key}")
def _scope_key(self, scope: ScopeContext) -> str:
"""Generate a unique key for a scope."""
return f"{scope.scope_type.value}:{scope.scope_id}"
def get_scope_depth(self, scope_type: ScopeLevel) -> int:
"""Get the depth of a scope level in the hierarchy."""
return self.SCOPE_ORDER.index(scope_type)
def get_parent_level(self, scope_type: ScopeLevel) -> ScopeLevel | None:
"""Get the parent scope level for a given level."""
idx = self.SCOPE_ORDER.index(scope_type)
if idx == 0:
return None
return self.SCOPE_ORDER[idx - 1]
def get_child_level(self, scope_type: ScopeLevel) -> ScopeLevel | None:
"""Get the child scope level for a given level."""
idx = self.SCOPE_ORDER.index(scope_type)
if idx >= len(self.SCOPE_ORDER) - 1:
return None
return self.SCOPE_ORDER[idx + 1]
def is_ancestor(
self,
potential_ancestor: ScopeContext,
descendant: ScopeContext,
) -> bool:
"""
Check if one scope is an ancestor of another.
Args:
potential_ancestor: Scope to check as ancestor
descendant: Scope to check as descendant
Returns:
True if ancestor relationship exists
"""
current = descendant.parent
while current is not None:
if (
current.scope_type == potential_ancestor.scope_type
and current.scope_id == potential_ancestor.scope_id
):
return True
current = current.parent
return False
def get_common_ancestor(
self,
scope_a: ScopeContext,
scope_b: ScopeContext,
) -> ScopeContext | None:
"""
Find the nearest common ancestor of two scopes.
Args:
scope_a: First scope
scope_b: Second scope
Returns:
Common ancestor or None if none exists
"""
# Get ancestors of scope_a
ancestors_a: set[str] = set()
current: ScopeContext | None = scope_a
while current is not None:
ancestors_a.add(self._scope_key(current))
current = current.parent
# Find first ancestor of scope_b that's in ancestors_a
current = scope_b
while current is not None:
if self._scope_key(current) in ancestors_a:
return current
current = current.parent
return None
def can_access(
self,
accessor_scope: ScopeContext,
target_scope: ScopeContext,
operation: str = "read",
) -> bool:
"""
Check if accessor scope can access target scope.
Access rules:
- A scope can always access itself
- A scope can access ancestors (if inheritance allowed)
- A scope CANNOT access descendants (privacy)
- Sibling scopes cannot access each other
Args:
accessor_scope: Scope attempting access
target_scope: Scope being accessed
operation: Type of operation (read/write)
Returns:
True if access is allowed
"""
# Same scope - always allowed
if (
accessor_scope.scope_type == target_scope.scope_type
and accessor_scope.scope_id == target_scope.scope_id
):
policy = self.get_policy(target_scope)
if operation == "write":
return policy.allows_write()
return policy.allows_read()
# Check if target is ancestor (inheritance)
if self.is_ancestor(target_scope, accessor_scope):
policy = self.get_policy(target_scope)
if not policy.allows_inherit():
return False
if operation == "write":
return policy.allows_write()
return policy.allows_read()
# Check if accessor is ancestor of target (downward access)
# This is NOT allowed - parents cannot access children's memories
if self.is_ancestor(accessor_scope, target_scope):
return False
# Sibling scopes cannot access each other
return False
# Singleton manager instance
_manager: ScopeManager | None = None
def get_scope_manager() -> ScopeManager:
"""Get the singleton scope manager instance."""
global _manager
if _manager is None:
_manager = ScopeManager()
return _manager