# How ai-memory Handles Scope Resolution: A Deep Dive Into the ScopeResolver Architecture

> Discover how ai-memory handles scope resolution with its ScopeResolver architecture. Learn about workspace and project isolation and strict data policies for efficient management.

- Repository: [Fabio Akita/ai-memory](https://github.com/akitaonrails/ai-memory)
- Tags: deep-dive
- Published: 2026-09-01

---

**ai-memory isolates data by workspace and project identifiers through a centralized ScopeResolver component that enforces strict read-only versus write-create policies.**

The `ai-memory` repository implements a robust multi-tenant data isolation system where every API request—whether from HTTP admin routes, MCP tools, or the read-only web API—must resolve human-readable scope names into concrete internal identifiers. This resolution logic lives in a single, auditable component: the **`ScopeResolver`**. Understanding this mechanism is essential for anyone extending the system or debugging permission issues.

## Core Scope Concepts

Before examining the resolution logic, you need to understand the three foundational types defined in [`crates/ai-memory-store/src/scope.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/scope.rs):

| Type | Purpose |
|------|---------|
| **`ScopeName`** | Holds the human-readable workspace and project names supplied by API callers. |
| **`ResolvedScope`** | Contains the internal `WorkspaceId` and `ProjectId` that the storage layer actually uses. |
| **`ScopeResolutionError`** | Exhaustive enum covering every failure mode: missing pairs, empty names, too many scopes, not-found errors, missing writers for create-on-write, and store failures. |

According to the ai-memory source code, these types form a clear boundary between user-facing identifiers and internal storage references. The resolver's job is to bridge this gap while maintaining strict safety invariants.

## Read-Only Resolution: Failing Closed

The **`ScopeResolver::resolve_read_args`** method implements ai-memory's read-resolution policy. Located at [`scope.rs:54-71`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/scope.rs#L54-L71), this method **never creates workspaces or projects**—it fails closed.

The resolution precedence works as follows:

1. **Explicit pair** – If both workspace and project are provided, resolve directly.
2. **Project-only with active project** – If only project is supplied, check the actor's **active-project** pointer (when attached via `with_active_project`).
3. **Fall back to defaults** – Use the resolver's configured default workspace and project.
4. **Reject partial pairs** – Workspace-only requests without project are rejected.

```rust
// Build a read-only resolver with fallback defaults
let resolver = ScopeResolver::new(&store.reader, default_ws_id, default_proj_id)
    .with_active_project(&active_project); // optional session context

// Explicit pair resolution
let scope = resolver
    .resolve_read_args(Some("team"), Some("frontend"), &actor_key)
    .await?;

// Project-only: falls back active-project → default
let scope = resolver
    .resolve_read_args(None, Some("shared-lib"), &actor_key)
    .await?;

```

Partial pairs trigger a `ScopeResolutionError`, preventing ambiguous lookups that could cross workspace boundaries.

## Write-Only Resolution: Create-on-Write Semantics

For mutations, ai-memory uses **`ScopeResolver::resolve_write_args`** at [`scope.rs:16-33`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/scope.rs#L16-L33). This path allows **automatic creation** of missing workspaces and projects, but only when a writer is explicitly attached.

Key behaviors:

- **Requires writer** – The resolver must be constructed with `with_writer(&store.writer)`; otherwise resolution fails with a missing-writer error.
- **Auto-creates missing entities** – Via `create_explicit_scope`, missing workspaces or projects are instantiated on demand.
- **Respects active-project context** – Unqualified project names resolve against the actor's current workspace when available.

```rust
// Upgrade to write-capable resolver
let resolver = resolver.with_writer(&store.writer);

// Creates "new-feature" project in actor's active workspace if absent
let new_scope = resolver
    .resolve_write_args(None, Some("new-feature"), &actor_key)
    .await?;

```

This create-on-write policy is documented in [`docs/auto-scope.md`](https://github.com/akitaonrails/ai-memory/blob/main/docs/auto-scope.md) and used consistently across the server's MCP tool implementations.

## Multi-Scope and Global Scope Handling

### Batch Resolution Without Creation

The **`resolve_many_existing`** method handles de-duplication and validation of explicit scope lists for operations affecting multiple projects. Unlike the single-scope methods, it operates strictly in read mode—no entities are created.

### Global Preferences Scope

ai-memory reserves a special "global" scope living in the default workspace. The **`lookup_global_scope`** helper at [`scope.rs:35-44`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/scope.rs#L35-L44) returns `None` until explicitly created via `create_global_scope`. This two-phase approach ensures global configuration cannot be accidentally instantiated through read paths.

## Safety Guarantees and Invariants

The ScopeResolver architecture enforces critical security properties through code structure rather than convention:

| Invariant | Enforcement |
|-----------|-------------|
| Reads never create | `resolve_read_args` lacks store writer access |
| Writes require explicit opt-in | `with_writer` attachment required for creation paths |
| Partial pairs rejected | `ScopeResolutionError::MissingPair` for ambiguous requests |
| No implicit global creation | `lookup_global_scope` returns `Option`, `create_global_scope` is separate |

These guarantees align with the architecture documentation's threat model, ensuring that a compromised read-only API key cannot pollute the workspace/project namespace.

## ScopeResolver Configuration and Usage Patterns

As implemented in [`crates/ai-memory-mcp/src/server.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-mcp/src/server.rs), each request constructs a fresh resolver:

```rust
// Per-request resolver instantiation pattern from the MCP server
let resolver = ScopeResolver::new(&store.reader, default_ws_id, default_proj_id);

// Attach session-specific context
let resolver = resolver.with_active_project(&actor.active_project);

// Conditionally enable writes for mutating operations
let resolver = if operation.requires_write {
    resolver.with_writer(&store.writer)
} else {
    resolver
};

```

The resolver's builder-pattern API allows precise capability restriction without proliferating parameter lists through the call stack.

## Summary

- **ai-memory scope resolution** centralizes all workspace/project lookup logic in the `ScopeResolver` component.
- **Read operations** use `resolve_read_args`, which never creates entities and follows explicit → active-project → default precedence.
- **Write operations** use `resolve_write_args`, which auto-creates missing scopes only when a writer is attached.
- **Active-project context** enables "current project" semantics without hardcoding session state into storage layer.
- **Global scope** follows explicit creation semantics, preventing accidental instantiation.
- **Comprehensive test coverage** at `scope.rs:94-180` documents expected behaviors and regression cases.

## Frequently Asked Questions

### How does ai-memory prevent read operations from accidentally creating workspaces or projects?

The `ScopeResolver` maintains strict separation between read-capable and write-capable instances. The `resolve_read_args` method accepts only a store reader, lacking any mechanism to persist new entities. Write paths explicitly require calling `with_writer(&store.writer)`, making create-on-write an opt-in capability that cannot be triggered through read-only codepaths.

### What happens when a request specifies only a project name without a workspace?

The resolver applies a three-tier fallback: first, it checks if the actor has an active project configured (via `with_active_project`); if set, it uses that workspace context. Otherwise, it falls back to the resolver's configured default workspace. This design supports both fully-qualified multi-tenant requests and convenient single-project operations within a session context.

### Can ai-memory handle operations across multiple projects in a single request?

Yes, through the `resolve_many_existing` method. This batch resolver de-duplicates scope names and validates all exist without creating any missing entities. It's designed for read-only aggregation queries or bulk operations where the caller must explicitly pre-create any needed projects through separate write requests.

### Where is the ScopeResolver instantiated in the ai-memory server architecture?

The MCP server in [`crates/ai-memory-mcp/src/server.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-mcp/src/server.rs) instantiates a fresh `ScopeResolver` per request using `ScopeResolver::new(&store.reader, default_ws_id, default_proj_id)`. This pattern ensures each request starts with minimal capabilities, with writers and active-project context attached only when the specific operation and authentication context permits.