# Workspace Isolation & Multi-Tenancy

> Every row is filtered by workspace_id. Every query key must contain the current wsId; changing workspace automatically swaps the visible data because the cache key changes. X-Workspace-ID header routes requests. setCurrentWorkspace(null, null) must be called explicitly before leave/delete or the next render will see stale data and hard-reload. Desktop tabs are grouped per workspace; cross-workspace push is rewritten by the navigation adapter into a workspace switch, never a navigation inside the current tab router.

- Repository: multica-ai/multica
- GitHub: https://github.com/multica-ai/multica
- Human wiki: https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf
- Complete Markdown: https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/llms-full.txt

## Source Files

- `packages/core/platform/workspace-storage.ts`
- `packages/core/types/workspace.ts`
- `server/internal/handler/scope_authorizer.go`
- `server/migrations/001_init.up.sql`
- `apps/desktop/src/renderer/src/platform/navigation.tsx`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [packages/core/platform/workspace-storage.ts](packages/core/platform/workspace-storage.ts)
- [packages/core/types/workspace.ts](packages/core/types/workspace.ts)
- [server/migrations/001_init.up.sql](server/migrations/001_init.up.sql)
- [apps/desktop/src/renderer/src/platform/navigation.tsx](apps/desktop/src/renderer/src/platform/navigation.tsx)
- [server/internal/middleware/workspace.go](server/internal/middleware/workspace.go)
- [packages/core/workspace/queries.ts](packages/core/workspace/queries.ts)
- [packages/core/api/client.ts](packages/core/api/client.ts)
- [packages/core/paths/hooks.tsx](packages/core/paths/hooks.tsx)
- [packages/views/settings/components/workspace-tab.tsx](packages/views/settings/components/workspace-tab.tsx)
- [apps/desktop/src/renderer/src/stores/tab-store.ts](apps/desktop/src/renderer/src/stores/tab-store.ts)
- [apps/web/app/[workspaceSlug]/layout.tsx](apps/web/app/[workspaceSlug]/layout.tsx)
- [server/internal/handler/handler.go](server/internal/handler/handler.go)
- [server/pkg/db/queries/issue.sql](server/pkg/db/queries/issue.sql)
</details>

# Workspace Isolation & Multi-Tenancy

Every persistent entity belongs to exactly one workspace. Isolation is enforced simultaneously at four layers: database rows, HTTP request routing, React Query cache keys plus namespaced client storage, and desktop tab groups. Changing the active workspace swaps the visible data set automatically because the identifiers used for queries, headers, and UI state all derive from the same slug/wsId pair. The design eliminates polling, manual cache merges, and cross-tenant leaks while making destructive workspace operations (leave, delete) safe only when an explicit context-clear step is performed first.

## Persistence Layer

The root of tenancy is the `workspace` table. Every domain table declares a `workspace_id UUID NOT NULL` foreign key with `ON DELETE CASCADE`:

```sql
CREATE TABLE issue (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
    ...
);
```

All generated queries filter explicitly on this column (example from the canonical issue list):

```sql
SELECT ... FROM issue i
WHERE i.workspace_id = $1
  AND ...
```

The same pattern appears for `member`, `agent`, `inbox_item`, `skill`, `squad`, `label`, `comment`, `project`, `pin`, `autopilot`, webhook deliveries, usage records, and every other tenant-scoped table. There is no path that can read or write rows without a workspace qualifier.

**Sources:** [server/migrations/001_init.up.sql:15-33](), [server/migrations/001_init.up.sql:52-72](), [server/pkg/db/queries/issue.sql:12-20]()

## Request Routing and Membership Enforcement

The API client attaches the current workspace identifier on every authenticated call:

```ts
// packages/core/api/client.ts
const slug = getCurrentSlug();
if (slug) headers["X-Workspace-Slug"] = slug;
```

The backend middleware (`RequireWorkspaceMember` and role variants) implements a strict resolution order:

1. `X-Workspace-Slug` header (or `?workspace_slug`) → lookup by slug.
2. `X-Workspace-ID` header (or `?workspace_id`) → direct UUID (CLI/daemon compatibility).

It then verifies the caller is a member via `GetMemberByUserAndWorkspace`, rejects with 404/403 when the lookup or membership check fails, and injects both the workspace UUID and the `Member` row into the request context via `SetMemberContext`. All subsequent handlers obtain the authoritative ID through `ctxWorkspaceID(ctx)` or `WorkspaceIDFromContext`; they never re-parse raw input for write operations.

**Sources:** [packages/core/api/client.ts:247-248](), [server/internal/middleware/workspace.go:69-84](), [server/internal/middleware/workspace.go:133-168](), [server/internal/handler/handler.go:353-364]()

## Frontend Cache and State Partitioning

`setCurrentWorkspace(slug, wsId)` (exported from `@multica/core/platform`) is the single source of truth. Platform route layouts call it on every render of a workspace-scoped segment after resolving the slug against the global workspace list query:

```ts
if (workspace) {
  setCurrentWorkspace(workspaceSlug, workspace.id);
}
```

Workspace-scoped React Query keys are built as `["workspaces", wsId, ...]` (list is the sole global key: `["workspaces", "list"]`). Hooks such as `useWorkspaceId()` and `useCurrentWorkspace()` derive the concrete ID by matching the URL slug (supplied via `WorkspaceSlugProvider`) against the list cache. When the slug changes, every dependent component receives a new key; the previous slice remains in cache but is no longer subscribed, producing an instantaneous, correct data swap with no invalidation calls.

Zustand persist stores that must survive a workspace switch are wrapped with `createWorkspaceAwareStorage`, which namespaces keys by the live `_currentSlug` at read/write time and returns `null` for writes when no workspace is active. The singleton also drives rehydration of persisted tab filters, drafts, and view preferences on slug change.

**Sources:** [packages/core/platform/workspace-storage.ts:34-43](), [packages/core/workspace/queries.ts:5-20](), [packages/core/paths/hooks.tsx:56-61](), [apps/web/app/[workspaceSlug]/layout.tsx:58-59]()

## Explicit Context Reset on Destructive Operations

Leave and delete flows must execute this exact sequence:

```ts
setCurrentWorkspace(null, null);
navigation.push(resolvePostAuthDestination(remaining, hasOnboarded));
// then fire the mutation
```

Clearing the singleton before the navigation and before the API call prevents three races:

- The realtime `workspace:deleted` / `member:removed` handler sees a stale "current" workspace and emits a competing navigation, producing `CancelledError` + full reload.
- Components still mounted under the settings page call `useWorkspaceId()` after the list no longer contains the old slug.
- The API client continues to emit the old `X-Workspace-Slug` on any in-flight request.

The route layout on the destination workspace (or the new-workspace overlay) is responsible for the subsequent non-null `setCurrentWorkspace` call.

**Sources:** [packages/views/settings/components/workspace-tab.tsx:75-97]()

## Desktop Tab Isolation

Desktop tabs are never a flat global list. The `TabStore` shape is:

```ts
{
  activeWorkspaceSlug: string | null,
  byWorkspace: Record<string, { tabs: Tab[], activeTabId: string }>
}
```

`switchWorkspace(slug, openPath?)` activates or seeds the group for that slug. The TabBar renders only the active group's tabs. The navigation adapter intercepts any `push(path)` whose leading segment differs from the current workspace slug and rewrites it as `switchWorkspace(targetSlug, path)` instead of a same-tab router navigation. Consequently, cross-workspace navigation never occurs inside a tab's memory router and cannot leak tabs between workspaces.

**Sources:** [apps/desktop/src/renderer/src/stores/tab-store.ts:50-57](), [apps/desktop/src/renderer/src/stores/tab-store.ts:260-309](), [apps/desktop/src/renderer/src/platform/navigation.tsx:102-109]()

## Invariants and Failure Modes

- Every row, every ws-scoped query key, every outbound header, and every desktop tab group must be partitioned by the identical workspace identifier.
- Removing the mandatory `setCurrentWorkspace(null, null)` before destructive navigation re-introduces the reload race that the pattern was written to eliminate.
- Passing a captured `wsId` value instead of the live value from `useWorkspaceId()` (or the explicit `wsId` parameter required by some hooks) produces stale data after a switch.
- Adding a new table or query without the `workspace_id` filter violates the tenancy contract at the persistence boundary.

The architecture therefore treats the workspace as the fundamental security, cache, and UI boundary rather than an optional filter.

**Sources:** [packages/core/platform/workspace-storage.ts:4-9](), [server/internal/middleware/workspace.go:170-225]()
