# How ai-memory Implements Per-Project Isolation Using `workspace_id`, `project_id`, `path` Tuples

> Learn how ai-memory ensures per-project isolation. It uses workspace_id, project_id, path tuples as a canonical primary key for data and filesystem access.

- Repository: [Fabio Akita/ai-memory](https://github.com/akitaonrails/ai-memory)
- Tags: internals
- Published: 2026-08-30

---

**The ai-memory system enforces strict data isolation by treating `(workspace_id, project_id, path)` as a three-field coordinate that serves as the canonical primary key for all domain tables and the on-disk filesystem layout.**

Every piece of data in ai-memory—from wiki pages to observations, sessions, and handoffs—is bound to this immutable tuple. This design guarantees that no two projects can collide, and no bug or malicious payload can access data across workspace boundaries. The architecture is implemented across the [akitaonrails/ai-memory](https://github.com/akitaonrails/ai-memory) repository using strongly-typed identifiers and a single-writer pattern.

## The Three-Field Coordinate System

The per-project isolation mechanism centers on three strongly-typed fields defined in [`crates/ai-memory-core/src/ids.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-core/src/ids.rs) (lines 80-92):

| Field | Type | Purpose |
|-------|------|---------|
| `workspace_id` | `WorkspaceId` | UUID v7 identifying the top-level workspace container |
| `project_id` | `ProjectId` | UUID v7 identifying a project within that workspace |
| `path` | `PagePath` | Normalized POSIX-style path identifying a page within the project |

These types are not simple aliases—they're newtype wrappers that prevent accidental mixing of identifiers at compile time. The tuple `(WorkspaceId, ProjectId, PagePath)` forms the **only valid coordinate** for any data operation.

## Filesystem Isolation via `Wiki::project_root`

The on-disk wiki layout mirrors the tuple structure exactly. In [`crates/ai-memory-wiki/src/wiki.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-wiki/src/wiki.rs) (lines 81-85), the `project_root` method constructs the base path:

```rust
// From crates/ai-memory-wiki/src/wiki.rs
fn project_root(&self, workspace_id: WorkspaceId, project_id: ProjectId) -> PathBuf {
    self.wiki_root()
        .join(workspace_id.to_string())
        .join(project_id.to_string())
}

```

This ensures the filesystem hierarchy is:

```

<wiki_root>/<workspace_id>/<project_id>/<page_path>

```

The `Wiki::abs_path` method is the **only sanctioned way** to resolve page paths. It concatenates all three tuple components, preventing ad-hoc string joins that could bypass isolation boundaries.

## Database Enforcement Through `WriterHandle`

All writes flow through the single-writer actor in [`crates/ai-memory-store/src/writer.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/writer.rs). This actor stamps `workspace_id` and `project_id` on every row across all domain tables:

- `pages`
- `observations`
- `sessions`
- `handoffs`

The SQLite schema reinforces this at creation time with a `UNIQUE(workspace_id, name)` constraint on the projects table, ensuring no duplicate `(workspace_id, project_id)` pairs exist.

### Writing a Page with Full Isolation

```rust
use ai_memory_core::ids::{WorkspaceId, ProjectId, PagePath};
use ai_memory_wiki::Wiki;

// Generate fresh UUID v7 identifiers
let ws_id = WorkspaceId::new();
let proj_id = ProjectId::new();

// Create Wiki handle (data_dir and writer are pre-configured)
let wiki = Wiki::new(&data_dir, writer);

// Define validated page path
let path = PagePath::new("notes/overview.md")?;

// Write page—tuple is automatically prefixed
wiki.write_page(ws_id, proj_id, path.clone(), "Hello world!".into())?;

// Resulting disk location:
// <data_dir>/wiki/<ws_id>/<proj_id>/notes/overview.md

```

### Querying Project-Scoped Data

```rust
use ai_memory_store::ReaderPool;

// Fetch all observations for a specific project tuple
let rows = reader
    .observations_by_ids(ws_id, proj_id, None)?; // None = all paths

```

Without both `ws_id` and `proj_id`, the query cannot execute—the API requires the complete coordinate.

## Moving Projects with Re-Stamp Operations

When a project must relocate to a different workspace, ai-memory performs an atomic **re-stamp** operation. The `move_project_workspace` method in [`crates/ai-memory-store/src/ops.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/ops.rs) (around line 5313) updates **every row** containing the old tuple in a single transaction:

```rust
use ai_memory_store::WriterHandle;

// Move project to new workspace
writer.move_project_workspace(
    proj_id,      // project to relocate
    old_ws_id,    // source workspace
    new_ws_id,    // destination workspace
)?;

```

This operation:

1. Locks the writer to prevent concurrent modifications
2. Updates all table rows where `(workspace_id, project_id)` matches
3. Updates the on-disk wiki directory structure
4. Commits as a single atomic transaction

The re-stamp logic ensures data integrity—there is no window where a project exists in two workspaces simultaneously.

## Security Guarantees of the Tuple Design

The three-field coordinate provides **defense in depth**:

- **Type safety**: `WorkspaceId` and `ProjectId` are distinct types—accidental swapping fails at compile time
- **Schema constraints**: SQLite unique constraints prevent duplicate project names within a workspace
- **API enforcement**: No public method accepts a bare `project_id` without its accompanying `workspace_id`
- **Filesystem isolation**: Path resolution requires all three tuple components via `Wiki::abs_path`

A bug or malicious actor knowing only a `project_id` cannot affect data in another workspace because the `workspace_id` component is mandatory for every lookup path.

## Summary

- **Per-project isolation** in ai-memory relies on a canonical `(workspace_id, project_id, path)` tuple that serves as the primary key for all data
- **Filesystem layout** at [`crates/ai-memory-wiki/src/wiki.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-wiki/src/wiki.rs) mirrors this tuple exactly under `<wiki_root>/<workspace_id>/<project_id>/<page_path>`
- **Single-writer enforcement** through `WriterHandle` stamps both IDs on every domain row
- **Atomic re-stamp** operations in [`crates/ai-memory-store/src/ops.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-store/src/ops.rs) enable safe project relocation
- **Strong typing** in [`crates/ai-memory-core/src/ids.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-core/src/ids.rs) prevents identifier confusion at compile time

## Frequently Asked Questions

### What happens if two projects have the same name in different workspaces?

The SQLite schema enforces `UNIQUE(workspace_id, name)` on the projects table, not `UNIQUE(name)`. Identical names are permitted across workspaces because the `workspace_id` component distinguishes them. The tuple `(workspace_id, project_id)` remains globally unique.

### Can a page path escape its project directory through traversal attacks?

No. `PagePath::new()` in [`crates/ai-memory-core/src/ids.rs`](https://github.com/akitaonrails/ai-memory/blob/main/crates/ai-memory-core/src/ids.rs) validates and normalizes all paths, rejecting any containing `..` components or absolute prefixes. The `Wiki::abs_path` method concatenates components without interpretation, making directory traversal impossible.

### How does the re-stamp operation maintain consistency during a workspace move?

The `move_project_workspace` method holds exclusive access to the `WriterHandle` throughout execution. All updates—database rows and filesystem directories—occur within a single SQLite transaction. If any step fails, the entire operation rolls back.

### Why use UUID v7 for workspace and project identifiers?

UUID v7 provides time-ordered, k-sortable identifiers that improve database locality and indexing performance while maintaining global uniqueness. The [`ids.rs`](https://github.com/akitaonrails/ai-memory/blob/main/ids.rs) implementation leverages this for efficient sequential storage in SQLite B-trees.