How ai-memory Resolves Scope Using the 3-Tuple Identity Model (workspace_id, project_id, path)
ai-memory resolves every operation to a concrete 3-tuple—consisting of a WorkspaceId, ProjectId, and normalized PagePath—using the ScopeResolver in ai-memory-store/src/scope.rs to convert user-supplied names into strongly-typed UUID identifiers.
The akitaonrails/ai-memory repository implements a hierarchical scoping system that guarantees isolation across workspaces and projects. By combining UUID v7-based identifiers with POSIX-style paths, the system creates an immutable addressing scheme for every page, observation, and handoff stored in the system.
Understanding the 3-Tuple Identity Model
The ai-memory 3-tuple identity model represents every piece of stored knowledge as a hierarchical address: (workspace_id, project_id, page_path). This structure ensures that even if two projects contain files with identical relative paths, they remain distinct entities within the global namespace.
Core Identifiers in ids.rs
The strongly-typed identifiers are defined in ai-memory-core/src/ids.rs. Both WorkspaceId and ProjectId are new-type wrappers around UUID v7 values generated via Uuid::now_v7(), ensuring global uniqueness and sortability. The PagePath type enforces strict invariants: no leading slashes, no parent-directory references (..), and no Windows drive prefixes, allowing the storage layer to treat paths as flat, canonical keys.
// Conceptual representation from ids.rs
pub struct WorkspaceId(Uuid); // v7 UUID
pub struct ProjectId(Uuid); // v7 UUID
pub struct PagePath(String); // Normalized "foo/bar.md"
Scope Resolution Logic in ai-memory
All scope resolution flows through ScopeResolver in ai-memory-store/src/scope.rs. This component translates human-readable workspace and project names into the concrete 3-tuple required by the storage layer.
The ScopeResolver Architecture
ScopeResolver acts as the single source of truth for converting user input into ResolvedScope objects. It maintains references to the ReaderPool for lookups and optionally holds a WriterHandle for auto-creating missing workspaces during write operations. The resolver returns a ResolvedScope struct containing the validated workspace_id and project_id, accessible via the as_tuple() method (lines 50-55).
Resolution Pathways
Explicit Pair Resolution: When both workspace and project strings are supplied, lookup_existing_scope (lines 81-98) queries the database to verify the workspace exists and that the project belongs to it.
Partial Pair Rejection: Supplying only a workspace without a project (or vice versa) triggers ScopeResolutionError::WorkspaceProjectPairRequired via resolve_read_args or resolve_write_args (lines 58-71, 124-136).
Project-Only Reads: The resolve_current_or_project method (lines 73-108) handles cases where only a project name is provided. It first checks the actor’s ActiveProject pointer (see below), then falls back to the server’s default workspace configuration.
Write-Style Resolution: resolve_write_args (lines 124-151) accepts an optional WriterHandle and may auto-create missing workspaces or projects, returning the newly minted UUIDs.
Multi-Scope Lookups: resolve_many_existing_scopes (lines 88-111) validates and deduplicates batches of explicit scopes without creating new entities.
The Active Project Pointer
During live sessions, ai-memory tracks the agent’s current working directory through ActiveProject in ai-memory-core/src/active_project.rs. This structure maintains a process-wide mapping of actors to their currently active (workspace_id, project_id) tuples.
ActiveProject supports three isolation modes:
- Single (default): One global slot shared across all sessions (last-write-wins)
- PerSession: Isolated by
session_id, allowing concurrent runs of the same user - PerActor: Full isolation using
(user, session_id)as the composite key
When ScopeResolver encounters a project-only read request, it calls ActiveProject.get_for(actor) (lines 51-74). If an active project exists for that actor, the resolver uses those IDs; otherwise, it defaults to the server’s configured fallback workspace.
Practical Implementation Examples
Resolving a Read Request with Project-Only Input
The following Rust example demonstrates resolving a scope when only the project name is known, leveraging the active project pointer:
use ai_memory_store::{ScopeResolver, ReaderPool};
use ai_memory_core::{ActiveProject, ActorKey};
async fn resolve_project_only(
reader: &ReaderPool,
active: &ActiveProject,
project_name: &str,
actor: &ActorKey,
) -> Result<(WorkspaceId, ProjectId), ScopeResolutionError> {
let default_ws = WorkspaceId::new(); // Injected at startup
let default_proj = ProjectId::new();
let resolver = ScopeResolver::new(reader, default_ws, default_proj)
.with_active_project(active);
// Resolves to actor's active workspace/project if set
let scope = resolver
.resolve_read_args(None, Some(project_name), actor)
.await?;
Ok(scope.as_tuple())
}
Resolving Write Operations with Auto-Creation
This example shows how resolve_write_args can instantiate missing workspaces during write operations:
use ai_memory_store::{ScopeResolver, WriterHandle, ReaderPool};
async fn resolve_write(
reader: &ReaderPool,
writer: &WriterHandle,
explicit_ws: Option<&str>,
explicit_proj: Option<&str>,
actor: &ActorKey,
) -> Result<(WorkspaceId, ProjectId), ScopeResolutionError> {
let resolver = ScopeResolver::new(reader, WorkspaceId::new(), ProjectId::new())
.with_writer(writer) // Enables creation
.with_active_project(&ActiveProject::new());
// Auto-creates workspace/project if missing
let scope = resolver
.resolve_write_args(explicit_ws, explicit_proj, actor)
.await?;
Ok(scope.as_tuple())
}
Batch Scope Resolution
For operations requiring multiple scopes (such as querying across projects), use resolve_many_existing:
use ai_memory_store::{ScopeResolver, ScopeName};
async fn resolve_multiple(
reader: &ReaderPool,
scopes: Vec<ScopeName>,
) -> Result<Vec<ResolvedScope>, ScopeResolutionError> {
let resolver = ScopeResolver::new(reader, WorkspaceId::new(), ProjectId::new());
// Validates all scopes exist; max 10 requested
resolver.resolve_many_existing(&scopes, 10).await
}
Downstream Usage of the 3-Tuple
Once resolved, the 3-tuple flows through several architectural layers:
- Storage Layer: All SQLite rows include
workspace_idandproject_idcolumns, enforcing physical isolation at the database level - Wiki Layer: Page content is addressed by the full triplet
(workspace_id, project_id, page_path), ensuring unique paths per project - MCP Tools: Tools like
memory_queryandmemory_writecallScopeResolverfirst, then pass the resolved tuple toReaderPool::queryorWriterHandle::write_page
Summary
- ai-memory uses a 3-tuple identity model (
workspace_id,project_id,path) to uniquely address every entity - Strongly-typed UUIDs (
WorkspaceId,ProjectId) and normalized PagePath values are defined inai-memory-core/src/ids.rs - ScopeResolver in
ai-memory-store/src/scope.rshandles all conversions from human-readable names to concrete IDs - The ActiveProject mechanism provides context-aware defaulting based on the actor’s current working directory
- Write operations can auto-create missing workspaces and projects, while read operations strictly validate existing scopes
Frequently Asked Questions
What is the 3-tuple identity model in ai-memory?
The 3-tuple identity model is a hierarchical addressing scheme where every piece of stored knowledge is identified by three components: a WorkspaceId (UUID), a ProjectId (UUID within that workspace), and a PagePath (normalized relative path). This ensures global uniqueness while maintaining logical organization, implemented in ai-memory-core/src/ids.rs.
How does ScopeResolver handle missing workspaces or projects?
For read operations, ScopeResolver returns specific errors like ScopeResolutionError::WorkspaceNotFound or ProjectNotFoundInWorkspace. For write operations via resolve_write_args, if a WriterHandle is provided and the workspace or project does not exist, the resolver creates them automatically using UUID v7 generation.
What happens when only a project name is provided without a workspace?
When resolving a project-only query, ScopeResolver first checks the actor’s ActiveProject pointer (managed in ai-memory-core/src/active_project.rs). If the actor has an active project set, it uses that workspace ID. If not, it falls back to the server’s default workspace configured at startup.
How does the ActiveProject isolation mode affect scope resolution?
ActiveProject supports three modes that determine how the "current project" is stored: Single mode shares one global slot across all sessions, PerSession isolates by session_id, and PerActor uses a composite key of (user, session_id). This affects which workspace/project tuple is returned when resolving implicit scopes in resolve_current_or_project.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →