# Volume / Workspace Architecture

This guide documents the current Volume / Workspace split in `xpert`.

There are only two core rules:

- `Volume` always means a filesystem path visible to the server process.
- `Workspace` always means a filesystem path visible to a sandbox.

They are related, but they are not the same thing.

## Why the split exists

The same underlying data can appear differently depending on where code is running:

- when the server runs directly on a developer machine, it sees local host paths
- when the server runs inside the API Docker container, it sees container paths
- when the sandbox runs in a local shell or a sibling container, it sees its own working directory

If all of those are called `workspacePath`, it becomes very easy to mix up:

- server-visible paths
- Docker bind sources
- sandbox working directories

The new architecture separates those layers and reconnects them through an explicit mapping.

## Core types

### `VolumeScope`

`VolumeScope` in `packages/server-ai/src/shared/volume/volume.ts` describes only the logical volume identity. It does not describe sandbox or provider behavior.

Main fields:

- `tenantId`
- `catalog`
- `projectId`
- `userId`
- `xpertId`
- `knowledgeId`
- `workspaceId`
- `environmentId`
- `rootId`
- `isolateByUser`

### `VolumeClient`

`VolumeClient` is responsible for server-side path resolution.

Main API:

- `resolve(scope): VolumeHandle`
- `resolveRoot(tenantId): { serverRoot, hostRoot }`

It does not manage sandbox lifecycle and it does not infer container working directories by itself.

### `VolumeHandle`

`VolumeHandle` is a resolved server-side volume.

Key fields:

- `scope`
- `serverRoot`
- `hostRoot`
- `publicBaseUrl`

Key methods:

- `ensureRoot()`
- `path(relative?)`
- `publicUrl(relative?)`
- `readFile()`
- `putFile()`
- `deleteFile()`
- `list()`

Semantically:

- `serverRoot` is the path the current server runtime can access directly
- `hostRoot` is the host path needed for Docker bind mounts
- `publicBaseUrl` is the URL prefix consumed by browsers and frontend links

### `WorkspacePathMapper`

`WorkspacePathMapper` converts a Volume into a Workspace view.

Main API:

- `mapVolumeToWorkspace(volume, options?)`
- `mapWorkspaceToVolume(binding, workspacePath)`

`WorkspaceBinding` describes one explicit mapping:

- `volumeRoot`
- `workspaceRoot`
- `workspacePath`
- `bindSource`
- `containerMountPath`

## Runtime implementations

### `DevVolumeClient`

`DevVolumeClient` represents the server running directly against the local filesystem.

In that case:

- `serverRoot === hostRoot`

Root rules:

- in `dev` without `SANDBOX_VOLUME`, use `~/data`
- otherwise prefer `${SANDBOX_VOLUME}/${tenantId}`
- if `SANDBOX_VOLUME` is still unavailable, fall back to `~/data/${tenantId}`

### `DockerVolumeClient`

`DockerVolumeClient` represents the server running inside the API Docker container.

In that case:

- `serverRoot` uses the API-container view, such as `/sandbox/${tenantId}`
- `hostRoot` uses the host view, such as `${SANDBOX_VOLUME}/${tenantId}`

### `LocalShellWorkspacePathMapper`

A local shell sandbox works directly against server-visible paths.

So:

- `workspaceRoot === volume.serverRoot`
- `workspacePath === volume.serverRoot` or one of its descendants

### `DockerWorkspacePathMapper`

A Docker sandbox mounts one precise Volume root into `/workspace` inside the container.

So:

- `bindSource === volume.hostRoot`
- `containerMountPath === '/workspace'`
- `workspaceRoot === '/workspace'`
- `workspacePath` is the server-side subpath translated into `/workspace/...`

## Runtime Workspace File Capability

Plugins, Agent middleware, and async queues should not read sandbox `/workspace/...` paths with Node `fs` directly. `/workspace` is the sandbox-visible mount point and may not exist at the same path in the API process or on the host server.

The platform exposes a single workspace-file entry point through runtime capabilities:

```ts
const workspaceFiles = context.runtime.capabilities.require(WorkspaceFilesRuntimeCapability)
const file = await workspaceFiles.readRuntimeBuffer('/workspace/report.docx')
```

`platform.workspace.files` accepts three runtime locator shapes:

- sandbox absolute paths: `/workspace/report.docx`
- workspace-relative paths: `report.docx` or `./report.docx`
- platform portable references: `{ source: 'platform.workspace.files', filePath, workspacePath, catalog, scopeId, ... }`

Resolution rules:

- `/workspace/foo.docx` or `workspaceRoot/foo.docx` is normalized to the Volume-relative path `foo.docx`.
- `foo.docx` and `./foo.docx` are resolved relative to the current Agent runtime workspace.
- Persisted portable references are read from their explicit catalog/scope.
- Default scope is inferred from the current Agent runtime: `projectId` resolves to `catalog: 'projects'`; otherwise `xpertId` resolves to `catalog: 'xperts'` with `isolateByUser: false`.

Safety rules:

- Empty paths, null bytes, `..`, the workspace root itself, and absolute paths escaping the current workspace root are rejected.
- Business plugins must not hardcode `/workspace -> /sandbox` or local host path mappings.
- Business records should not persist `/workspace/...`, `/sandbox/...`, or host absolute paths as file references.

Read APIs return both file bytes and a platform-generated `WorkspacePortableFileReference`. For delayed sending, retries, or async processing, persist only the portable reference plus validation fields such as `size` and `sha256`; do not store base64 or buffers in queue payloads.

Use `writeRuntimeBuffer()` for new files:

```ts
const written = await workspaceFiles.writeRuntimeBuffer({
  path: 'exports/report.docx',
  originalName: 'report.docx',
  mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  buffer
})

await outboundQueue.enqueue({
  fileRef: written.reference,
  size: written.size ?? buffer.length
})
```

This keeps business plugins focused on file references while the platform owns path normalization, scope inference, Volume IO, and tenant isolation.

## Root path matrix

Root calculation is centralized in [packages/server-ai/src/shared/volume/volume-layout.ts](/Users/xpertai03/Pro/data/xpert/packages/server-ai/src/shared/volume/volume-layout.ts:1).

### API-container root

`getApiContainerSandboxVolumeRootPath(tenantId)`:

| Scenario                                                 | Value                           |
| -------------------------------------------------------- | ------------------------------- |
| `dev` without `SANDBOX_VOLUME`                           | `~/data`                        |
| `dev`, server not in Docker, `SANDBOX_VOLUME` configured | `${SANDBOX_VOLUME}/${tenantId}` |
| `dev`, server in Docker                                  | `/sandbox/${tenantId}`          |
| non-`dev`                                                | `/sandbox/${tenantId}`          |

### Host root

`getDockerHostSandboxVolumeRootPath(tenantId)`:

| Scenario                                  | Value                                |
| ----------------------------------------- | ------------------------------------ |
| `dev` without `SANDBOX_VOLUME`            | `~/data`                             |
| `SANDBOX_VOLUME` configured               | `${SANDBOX_VOLUME}/${tenantId}`      |
| no `SANDBOX_VOLUME` outside flattened dev | falls back to the API-container root |

### Flattened local development

Flattened mode is enabled when both are true:

- `environment.envName === 'dev'`
- `SANDBOX_VOLUME` is not configured

In that mode:

- `serverRoot` and `hostRoot` both collapse to `~/data`
- all logical catalogs share that one root
- public URLs are normalized by `normalizeSandboxPublicVolumeSubpath()`

That is the main reason why local-development disk roots and logical public URLs may not look identical.

## Logical Volume subpaths

`getVolumeSubpath()` defines the logical subpaths:

| `catalog`              | Subpath                            |
| ---------------------- | ---------------------------------- |
| `environment`          | `/environment/${environmentId}`    |
| `knowledges`           | `/knowledges/${knowledgeId}`       |
| `projects`             | `/project/${projectId}`            |
| `skills`               | `/skills/${rootId}`                |
| `users`                | `/user/${userId}`                  |
| `workspaces`           | `/workspaces/${workspaceId}`       |
| `xperts` shared        | `/xpert/${xpertId}`                |
| `xperts` user-isolated | `/xpert/${xpertId}/user/${userId}` |

From that subpath we derive:

- the Volume path: `volume.serverRoot`
- the public URL: `getVolumePublicBaseUrl(scope)`

Examples:

| Logical object             | Server path                                            | Public URL                                            |
| -------------------------- | ------------------------------------------------------ | ----------------------------------------------------- |
| Project                    | `/sandbox/${tenantId}/project/${projectId}`            | `/api/sandbox/volume/project/${projectId}`            |
| User                       | `/sandbox/${tenantId}/user/${userId}`                  | `/api/sandbox/volume/user/${userId}`                  |
| Shared Xpert volume        | `/sandbox/${tenantId}/xpert/${xpertId}`                | `/api/sandbox/volume/xpert/${xpertId}`                |
| User-isolated Xpert volume | `/sandbox/${tenantId}/xpert/${xpertId}/user/${userId}` | `/api/sandbox/volume/xpert/${xpertId}/user/${userId}` |
| Workspace                  | `/sandbox/${tenantId}/workspaces/${workspaceId}`       | `/api/sandbox/volume/workspaces/${workspaceId}`       |

## Xpert Work Area Rules

Xpert runtime working directories are resolved by `XpertWorkAreaResolver` in `packages/server-ai/src/shared/volume/work-area.ts`.

The design rules are:

- The default working directory is the current Xpert / project Volume root.
- The Xpert / project root is the default shared workspace; when multiple users share one Xpert, shared files are expected.
- `shared` remains as an explicit shared subdirectory for backward compatibility and tools that need a stable shared path.
- Xpert long-term FileMemory is shared at Xpert level and is not isolated by user.
- Agent invoke, terminal, sandbox upload, conversation workspace files, and managed sandbox services should all use the same resolver output so the Agent cwd and terminal cwd do not diverge.
- Conversation workspace file APIs must browse the project / Xpert Volume root directly. They should not add a `users/<userId>` subtree under the root.

Canonical layout:

```text
/sandbox/<tenantId>/
  project/<projectId>/
    shared/
    agents/<xpertId>/
    sessions/<conversationId>/
    .xpert/

  xpert/<xpertId>/
    shared/
    .xpert/memory/

  user/<userId>/
  environment/<environmentId>/
```

### Project conversations

When `projectId` exists, the resolver uses the project Volume:

```ts
{
  tenantId,
  catalog: 'projects',
  projectId,
  userId
}
```

Relative path rules:

| Purpose                                  | Relative path inside the Volume |
| ---------------------------------------- | ------------------------------- |
| Default cwd                              | Volume root                     |
| Project explicit shared subdirectory     | `shared`                        |
| Xpert agent workspace inside the project | `agents/<xpertId>`              |
| Single-conversation temporary area       | `sessions/<conversationId>`     |
| Project internal state                   | `.xpert`                        |

In a local shell sandbox, `workspace_path` is a server-visible path, for example:

```text
/sandbox/<tenantId>/project/<projectId>
```

In a Docker sandbox, the same directory is mapped to:

```text
/workspace
```

### Non-project Xpert conversations

When there is no `projectId` and an `xpertId` exists, the resolver uses the shared Xpert Volume:

```ts
{
  tenantId,
  catalog: 'xperts',
  xpertId,
  userId,
  isolateByUser: false
}
```

Relative path rules:

| Purpose                            | Relative path inside the Volume |
| ---------------------------------- | ------------------------------- |
| Default cwd                        | Volume root                     |
| Xpert explicit shared subdirectory | `shared`                        |
| Xpert long-term FileMemory         | `.xpert/memory`                 |

So when multiple users talk to the same Xpert:

- File operations default to the same `xpert/<xpertId>` workspace root.
- Tools that need a stable shared subdirectory may still use `xpert/<xpertId>/shared`.
- `memory_write`, Dream, and memory file APIs all read and write the same `xpert/<xpertId>/.xpert/memory`.

### Docker container example

Assume:

- `tenantId = tenant-1`
- `userId = user-A`
- `xpertId = xpert-X`
- this is a non-project conversation
- the sandbox provider is Docker

The API service container sees this server-side Volume:

```text
/sandbox/tenant-1/xpert/xpert-X/
  shared/
  .xpert/memory/
```

Directory meanings:

| Directory                                       | Purpose                                               |
| ----------------------------------------------- | ----------------------------------------------------- |
| `/sandbox/tenant-1/xpert/xpert-X`               | Default working directory for Xpert X                 |
| `/sandbox/tenant-1/xpert/xpert-X/shared`        | Explicit shared subdirectory for Xpert X              |
| `/sandbox/tenant-1/xpert/xpert-X/.xpert/memory` | Long-term FileMemory for Xpert X, shared by all users |

The same Volume is mounted into the sandbox Docker container at `/workspace`, so Agent, terminal, and sandbox tools see:

```text
/workspace
/workspace/shared
/workspace/.xpert/memory
```

Mapping:

| API service container path                      | Sandbox Docker path        | Meaning                                                                                     |
| ----------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------- |
| `/sandbox/tenant-1/xpert/xpert-X`               | `/workspace`               | Default cwd; ordinary generated files, uploads, and terminal commands write here by default |
| `/sandbox/tenant-1/xpert/xpert-X/shared`        | `/workspace/shared`        | Explicit shared subdirectory                                                                |
| `/sandbox/tenant-1/xpert/xpert-X/.xpert/memory` | `/workspace/.xpert/memory` | Long-term memory directory for `memory_write`, Dream, and memory file APIs                  |

If user B talks to the same Xpert X, the default file directory remains the same Xpert workspace:

```text
/sandbox/tenant-1/xpert/xpert-X
/workspace
```

But user A and user B share the same Xpert memory directory:

```text
/sandbox/tenant-1/xpert/xpert-X/.xpert/memory
/workspace/.xpert/memory
```

If the conversation belongs to project `project-1`, the default directory switches to the project Volume:

```text
/sandbox/tenant-1/project/project-1
/workspace
```

Project shared, agent, and session directories are:

```text
/sandbox/tenant-1/project/project-1/shared
/sandbox/tenant-1/project/project-1/agents/xpert-X
/sandbox/tenant-1/project/project-1/sessions/<conversationId>
```

In a Docker sandbox, they map to:

```text
/workspace/shared
/workspace/agents/xpert-X
/workspace/sessions/<conversationId>
```

### Conversation workspace file APIs

The ClawXpert file panel and related preview/edit flows use the conversation file endpoints:

```text
GET    /api/chat-conversation/:conversationId/files
GET    /api/chat-conversation/:conversationId/file
POST   /api/chat-conversation/:conversationId/file
POST   /api/chat-conversation/:conversationId/file/upload
DELETE /api/chat-conversation/:conversationId/file
```

These endpoints use the conversation only to resolve the current workspace root:

| Conversation context             | Volume scope                                           | Subtree scope |
| -------------------------------- | ------------------------------------------------------ | ------------- |
| `projectId` exists               | `catalog: 'projects'`, `projectId`                     | Volume root   |
| no `projectId`, `xpertId` exists | `catalog: 'xperts'`, `xpertId`, `isolateByUser: false` | Volume root   |
| no `projectId` and no `xpertId`  | invalid for workspace file access                      | N/A           |

For a non-project ClawXpert conversation, `/api/chat-conversation/:id/files` therefore lists the shared Xpert workspace root:

```text
/sandbox/<tenantId>/xpert/<xpertId>
/workspace
```

The `conversationId` is still used for lookup and permission context, but it is not a filesystem directory segment. Users sharing the same Xpert see the same workspace files.

### Workspace File Relative Path Resolution

Plugins, managed connections, and runtime capabilities should record workspace files as "relative path plus catalog/scope", not as an absolute path from the current process or sandbox.

Standard input shape:

```ts
{
  tenantId,
  userId,
  catalog: 'projects' | 'users' | 'knowledges' | 'skills' | 'xperts',
  scopeId,
  filePath: 'files/wechat/<integrationId>/<messageId>/<fileName>'
}
```

The shared resolver lives in:

```text
packages/server-ai/src/file-understanding/domain/workspace-file.ts
```

It maps `catalog` / `scopeId` to `VolumeScope` consistently:

| catalog      | Scope fields               | Volume scope output                                     |
| ------------ | -------------------------- | ------------------------------------------------------- |
| `projects`   | `projectId` or `scopeId`   | `{ catalog: 'projects', projectId, userId }`            |
| `xperts`     | `xpertId` or `scopeId`     | `{ catalog: 'xperts', xpertId, userId, isolateByUser }` |
| `users`      | `scopeId` or `userId`      | `{ catalog: 'users', userId }`                          |
| `knowledges` | `knowledgeId` or `scopeId` | `{ catalog: 'knowledges', knowledgeId, userId }`        |
| `skills`     | `rootId` or `scopeId`      | `{ catalog: 'skills', rootId, userId }`                 |

Readers should pass only the relative path to `VolumeHandle.path(relativePath)`:

```ts
const { volumeScope } = resolveWorkspaceVolumeScope(input, { tenantId, userId })
const absolutePath = volumeClient.resolve(volumeScope).path(input.filePath)
```

Do not pass `files/wechat/...` directly to Node `fs.open()`. That path would be resolved against the API process `cwd`, which can produce `ENOENT` in production even when the workspace file panel shows the file. Also do not persist `/workspace/...`, `/sandbox/...`, or local runtime directories as business paths; those are runtime / sandbox mappings and are not portable across deployments.

### Environment sandboxes

When `sandboxEnvironmentId` exists, the environment sandbox has the highest priority and ignores project context:

```ts
{
  tenantId,
  catalog: 'environment',
  environmentId,
  userId
}
```

The default cwd remains the environment Volume root, `environment/<environmentId>`. These directories are for explicit environment-level sandboxes and do not participate in the project / Xpert user-directory split.

### Xpert FileMemory

The canonical Xpert FileMemory root is:

```text
xpert/<xpertId>/.xpert/memory
```

The full server-side path is:

```text
/sandbox/<tenantId>/xpert/<xpertId>/.xpert/memory
```

Notes:

- New writes no longer use `.xpert/memory/xperts/<xpertId>`.
- Topic frontmatter still keeps `scopeId=<xpertId>` so each memory entry states which Xpert owns it.
- FileMemory uses `catalog: 'xperts'` and `isolateByUser: false`.
- On first access, the service lazily migrates from the legacy `.xpert/memory/xperts/<xpertId>` path in a non-destructive way: it copies only files missing from the new root, keeps new files on conflict, and writes a migration report under `.dream/migrations/`.

## Knowledge Work Area Rules

Knowledgebases and knowledge pipelines use the separate `KnowledgeWorkAreaResolver`. It shares the same Volume / Workspace mapping model as Xpert work areas, but it does not merge knowledge files into the Xpert conversation default cwd.

Design rules:

- A knowledgebase is a shared tenant-scoped data asset, with directories scoped by `knowledgebaseId`.
- Multiple users, Xperts, and pipelines may read and write the same knowledgebase Volume.
- User-private conversation directories and Xpert FileMemory do not store knowledge source files.
- Pipeline temporary files are scoped to a single task when a task id is available, so concurrent ingestion runs do not share one `tmp` directory.

Canonical layout:

```text
/sandbox/<tenantId>/
  knowledges/<knowledgebaseId>/
    files/
    users/<userId>/staging/
    documents/<documentId>/
    pipeline/runs/<taskId>/
    tmp/
    .knowledge/
```

Relative path rules:

| Purpose                                                | Relative path inside the Volume |
| ------------------------------------------------------ | ------------------------------- |
| Canonical knowledge source files                       | `files`                         |
| User import staging                                    | `users/<userId>/staging`        |
| Per-document derived assets                            | `documents/<documentId>`        |
| Per-task pipeline run workspace                        | `pipeline/runs/<taskId>`        |
| Per-task pipeline temporary directory                  | `pipeline/runs/<taskId>/tmp`    |
| Legacy-compatible temporary directory                  | `tmp`                           |
| Knowledgebase internal state, locks, migration reports | `.knowledge`                    |

### Knowledge uploads

The knowledgebase file upload endpoint still resolves the Volume with `catalog: 'knowledges'` and `knowledgeId`, but new files are written under the `files/` subdirectory.

Example:

```text
/sandbox/<tenantId>/knowledges/<knowledgebaseId>/files/<folder>/manual-<unix>-<random>.pdf
```

The returned `filePath` stored on the document is relative to the knowledge Volume, for example:

```text
files/<folder>/manual-<unix>-<random>.pdf
```

Reads still resolve through `volume.path(doc.filePath)`, so older document paths without the `files/` prefix remain readable.

### Knowledge pipelines

Knowledge pipeline processors and transformers use the same knowledge Volume:

```ts
{
  tenantId,
  catalog: 'knowledges',
  knowledgeId,
  userId
}
```

When `taskId` exists, the default run workspace is:

```text
/sandbox/<tenantId>/knowledges/<knowledgebaseId>/pipeline/runs/<taskId>
```

The transformer temporary directory is:

```text
/sandbox/<tenantId>/knowledges/<knowledgebaseId>/pipeline/runs/<taskId>/tmp
```

Regular document processing without `taskId` uses the document-scoped temporary directory when `documentId` is available:

```text
/sandbox/<tenantId>/knowledges/<knowledgebaseId>/documents/<documentId>/tmp
```

Preview flows without a task or document context still fall back to:

```text
/sandbox/<tenantId>/knowledges/<knowledgebaseId>/tmp
```

If a future knowledge pipeline runs inside a Docker sandbox, the same Volume maps to `/workspace`:

```text
/workspace/files
/workspace/pipeline/runs/<taskId>
/workspace/pipeline/runs/<taskId>/tmp
/workspace/.knowledge
```

### Runtime `sys` fields

Agent runtime writes the resolver output into `sys`:

| Field                    | Meaning                                                                                        |
| ------------------------ | ---------------------------------------------------------------------------------------------- |
| `volume`                 | Server-side Volume root                                                                        |
| `workspace_path`         | Sandbox-side default cwd                                                                       |
| `workspace_url`          | Public URL for the default cwd                                                                 |
| `workspace_root`         | Sandbox-side mount root                                                                        |
| `shared_workspace_path`  | Shared workspace path                                                                          |
| `agent_workspace_path`   | Project-scoped Xpert agent workspace path; only set in project context                         |
| `session_workspace_path` | Single-conversation temporary path; only set when a project conversation has a conversation id |
| `memory_workspace_path`  | Xpert long-term memory path; only set in non-project Xpert context                             |

`workspace_path`, `workspace_url`, and `volume` are compatibility fields and must remain. New code that needs shared, agent, session, or memory directories should prefer the explicit new fields.

## Additional path families

### Workspace paths

From [packages/server-ai/src/xpert-workspace/types.ts](/Users/xpertai03/Pro/data/xpert/packages/server-ai/src/xpert-workspace/types.ts:1):

- workspace root: `${volumeRoot}/workspaces/${workspaceId}`

From [packages/server-ai/src/skill-repository/types.ts](/Users/xpertai03/Pro/data/xpert/packages/server-ai/src/skill-repository/types.ts:1):

- workspace skills root: `${workspaceRoot}/skills`

### Organization shared skill paths

Also from [packages/server-ai/src/skill-repository/types.ts](/Users/xpertai03/Pro/data/xpert/packages/server-ai/src/skill-repository/types.ts:1):

- organization shared skills root: `${volumeRoot}/organizations/${organizationId}/skills/public`
- global shared skills root: `${volumeRoot}/skills/public`

## Mapping Work Areas into Workspace

Generic Volume usage can still use the two-step flow:

1. resolve a Volume
2. map it into a Workspace

But runtime entry points such as Xpert conversations, project conversations, terminal, sandbox upload, managed services, and Agent invoke should prefer `XpertWorkAreaResolver`.

Recommended usage:

```ts
const workArea = await this.workAreaResolver.resolve({
  tenantId,
  userId,
  provider,
  xpertId,
  projectId,
  conversationId,
  environmentId
})
```

The result means:

- `workArea.volumePath` identifies the server-side Volume root
- `workArea.workspaceBinding` is passed to the sandbox provider for mounting
- `workArea.workingDirectory` is the sandbox cwd
- `workArea.sharedPath`, `agentPath`, `sessionPath`, and `memoryPath` are explicit business paths

## Field semantics in runtime flows

Under the new model, the common runtime fields should keep these meanings:

- `sys.volume`: server-side Volume path
- `sys.workspace_path`: sandbox-side Workspace path
- `sandbox.workingDirectory`: sandbox-side Workspace path
- `workspaceBinding`: explicit mapping between Volume and Workspace

Do not pass a server path such as `/sandbox/...` directly into a Docker sandbox `workingDirectory`.

## Skills middleware fallback sandbox

Skills middleware normally reads the runtime sandbox from `request.runtime.configurable.sandbox`. If a tool call needs a backend and the runtime sandbox is missing, the fallback sandbox must use the same workspace ownership rules:

| Context                          | Fallback Volume scope                                  |
| -------------------------------- | ------------------------------------------------------ |
| `projectId` exists               | `catalog: 'projects'`, `projectId`                     |
| no `projectId`, `xpertId` exists | `catalog: 'xperts'`, `xpertId`, `isolateByUser: false` |
| neither exists                   | `catalog: 'users'`, `userId`                           |

This prevents skill file reads or skill shell execution from silently switching a ClawXpert run back to a private `user/<userId>` Volume.

## File-upload semantics

Three target modes matter most here:

### `volume`

- the destination path is a server-side Volume
- the returned link is a public URL

### `sandbox:mounted_workspace`

- `workspacePath` now means a server-visible mounted path
- in practice it writes directly into the Volume mounted into the server

### `sandbox:backend_upload`

- `workspaceId` and backend `workspace_id` refer to the sandbox-side working directory
- the actual file write happens inside the sandbox backend

Those path families should not be mixed together.

## `VolumeSubtreeClient`

The older `WorkspaceVolumeClient` has been reframed as `VolumeSubtreeClient`, defined in [packages/server-ai/src/shared/volume/volume-subtree.ts](/Users/xpertai03/Pro/data/xpert/packages/server-ai/src/shared/volume/volume-subtree.ts:1).

Its job is not to represent a full Workspace. Instead it:

- browses a subtree below a `VolumeHandle`
- reads and writes text files in a constrained subtree
- generates file URLs relative to that subtree

It is a good fit for:

- conversation workspace file APIs, using the project / Xpert Volume root as the subtree
- xpert memory file directories
- controlled subdirectories inside projects or workspaces

## Dependency injection entry points

The new architecture is exposed through [packages/server-ai/src/shared/volume/volume.module.ts](/Users/xpertai03/Pro/data/xpert/packages/server-ai/src/shared/volume/volume.module.ts:1):

- `VOLUME_CLIENT`
- `WorkspacePathMapperFactory`
- `XpertWorkAreaResolver`
- `KnowledgeWorkAreaResolver`

Recommended usage:

```ts
@Inject(VOLUME_CLIENT)
private readonly volumeClient: VolumeClient
```

For Xpert runtime entry points, inject the resolver directly:

```ts
constructor(
  private readonly workAreaResolver: XpertWorkAreaResolver
) {}
```

For knowledgebase and knowledge pipeline entry points, inject:

```ts
constructor(
  private readonly knowledgeWorkAreaResolver: KnowledgeWorkAreaResolver
) {}
```

Do this instead of manually joining `project/users/agents/sessions/.xpert/memory` or `knowledges/files/tmp` in business code.

## Recommended usage

- Decide first whether you need a Volume or a Workspace.
- Use `VolumeHandle` when the server must read or write files.
- Use `WorkspacePathMapper` when a sandbox needs an execution path.
- Use `XpertWorkAreaResolver` when resolving Xpert, project, or environment runtime directories.
- Use `KnowledgeWorkAreaResolver` when resolving knowledgebase uploads, knowledge pipelines, or document-derived assets.
- Use `VolumeSubtreeClient` when you need a controlled subtree browser.
- Keep the old static helpers only for compatibility, not as the main entry point for new code.
