# How ai-memory Resolves Scope Using the 3-Tuple Identity Model (workspace_id, project_id, path)

> Learn how ai-memory resolves scope using its 3-tuple identity model (workspace_id, project_id, path). Discover how the ScopeResolver converts names to UUID identifiers for precise operations.

- Repository: [Fabio Akita/ai-memory](https://github.com/akitaonrails/ai-memory)
- Tags: how-to-guide
- Published: 2026-08-22

---

**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`](https://github.com/akitaonrails/ai-memory/blob/main/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`](https://github.com/akitaonrails/ai-memory/blob/main/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.

```rust
// 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`](https://github.com/akitaonrails/ai-memory/blob/main/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`](https://github.com/akitaonrails/ai-memory/blob/main/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:

```rust
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:

```rust
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`:

```rust
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_id` and `project_id` columns, 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_query` and `memory_write` call `ScopeResolver` first, then pass the resolved tuple to `ReaderPool::query` or `WriterHandle::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 in [`ai-memory-core/src/ids.rs`](https://github.com/akitaonrails/ai-memory/blob/main/ai-memory-core/src/ids.rs)
- **ScopeResolver** in [`ai-memory-store/src/scope.rs`](https://github.com/akitaonrails/ai-memory/blob/main/ai-memory-store/src/scope.rs) handles 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`](https://github.com/akitaonrails/ai-memory/blob/main/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`](https://github.com/akitaonrails/ai-memory/blob/main/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`.