Agent-readable wiki
Multica Mental Model Wiki
A mental model of how Multica makes humans and agents first-class peers through workspace-scoped data, strict state ownership, package boundaries, polymorphic execution, and real-time invalidation — enabling the same logic to run safely on web and desktop.
Pages
- The Mental ModelThe unifying picture: agents are teammates (not tools), multiplexing work like Multics for small human+AI teams. Workspace is the atomic unit of isolation and visibility. Server data lives only in TanStack Query; client UI state lives only in Zustand. WS events never write state — they only invalidate. The same business logic runs on web and desktop because three package layers plus thin platform adapters enforce the boundaries.
- The State Ownership ContractReact Query owns every server fact (issues, agents, members, inbox, workspace list). Zustand owns only ephemeral client facts (current tab, filters, drafts, modal open/closed). Copying a query result into a store creates two drifting sources of truth. WS listeners only call queryClient.invalidateQueries; they never call store setters. Auth and workspace stores are the sole exceptions allowed to call api.* directly because queries cannot run until they exist. Selectors must return stable references or the UI will re-render forever.
- Workspace Isolation & Multi-TenancyEvery 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.
- The Package Contract: Core, UI, ViewsThree layers with iron boundaries. packages/core exports raw .ts/.tsx (no build step), contains every Zustand store and the API client, and forbids react-dom, localStorage, and process.env. packages/ui contains only pure Base UI + shadcn components and may never import @multica/core. packages/views contains all business screens and may never import next/* or react-router-dom; it receives routing via NavigationAdapter injected by the platform layer. Each consuming app supplies its own thin adapter (web/platform/navigation.tsx, desktop/.../navigation.tsx) and wraps its root with CoreProvider. This is why the identical page component works in both apps and survives HMR.
- Backend Safety RailsThree UUID rules prevent silent zero-row deletes and 204 responses that lie. Resource path params that can be human slugs (MUL-123) or UUIDs must go through loadIssueForUser / loadAgentForUser first; the resolved .ID is then used for all subsequent queries. Pure-UUID inputs from the wire use parseUUIDOrBadRequest which returns 400 on failure. Trusted round-trips (sqlc results, test fixtures) may use MustParseUUID which panics — a deliberate signal that unvalidated user input reached it. WS listeners (activity, autopilot, runtime sweeper) exist only to invalidate queries; they never mutate client state. Every handler that performs a write must ask “where did this UUID come from?”
- Navigation, Tabs & Route IsolationuseNavigation().push and AppLink are the only allowed navigation primitives in shared code. Desktop distinguishes three route categories: session routes (real tabs under WorkspaceRouteLayout), transition flows (create workspace, accept invite — rendered as WindowOverlay state, never real routes), and error states (never rendered; stale tabs are healed by dropping the tab group). The navigation adapter detects cross-workspace push and calls switchWorkspace instead of letting the memory router navigate inside the wrong tab. Web uses ordinary Next.js routes plus searchParams; desktop memory router + tab-store keep each workspace’s tabs isolated by construction.
- Agent Execution & Runtime ModelAssignee is polymorphic (assignee_type + assignee_id). An agent can be assigned exactly like a member; it claims work, streams progress, posts comments, and may create new issues. Two runtime surfaces exist: local daemon (CLI-managed, talks to desktop via daemon-ipc-bridge) and cloud runtimes. The daemon advertises available CLIs; the sweeper reaps dead runs. Every completed execution becomes a reusable skill stored under the workspace; future agents can invoke the same skill without re-learning. Health derivation and version detection live in packages/core/runtimes so both apps see identical status without duplication.
- Core Invariants & Safe Evolution RulesThe system stays coherent only while these hold: (1) server facts never live in Zustand, (2) WS events only invalidate, (3) every workspace-scoped query keys on wsId, (4) mutations are optimistic then invalidate, (5) core never touches react-dom or framework routers, (6) views never import next/* or react-router, (7) UUIDs from the wire are validated before any write query, (8) desktop destructive workspace ops call setCurrentWorkspace(null) before the mutation. Violating any produces drift, white-screens on older desktop builds, or cross-workspace data leaks. To change safely: add the Zod response schema in the same PR, write a test that feeds it malformed data, run make check, and only then consider the contract stable. These rules are the price of a desktop app that is always older than the server it talks to.
Complete Markdown
# Multica Mental Model Wiki
> A mental model of how Multica makes humans and agents first-class peers through workspace-scoped data, strict state ownership, package boundaries, polymorphic execution, and real-time invalidation — enabling the same logic to run safely on web and desktop.
## Context Links
- [Agent index](https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/llms.txt)
- [Human interactive wiki](https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf)
- [GitHub repository](https://github.com/multica-ai/multica)
## Repository Metadata
- Repository: multica-ai/multica
- Generated: 2026-05-21T23:30:56.122Z
- Updated: 2026-05-21T23:31:38.719Z
- Runtime: Grok CLI
- Format: Mental Model
- Pages: 8
## Page Index
- 01. [The Mental Model](https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/01-the-mental-model.md) - The unifying picture: agents are teammates (not tools), multiplexing work like Multics for small human+AI teams. Workspace is the atomic unit of isolation and visibility. Server data lives only in TanStack Query; client UI state lives only in Zustand. WS events never write state — they only invalidate. The same business logic runs on web and desktop because three package layers plus thin platform adapters enforce the boundaries.
- 02. [The State Ownership Contract](https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/02-the-state-ownership-contract.md) - React Query owns every server fact (issues, agents, members, inbox, workspace list). Zustand owns only ephemeral client facts (current tab, filters, drafts, modal open/closed). Copying a query result into a store creates two drifting sources of truth. WS listeners only call queryClient.invalidateQueries; they never call store setters. Auth and workspace stores are the sole exceptions allowed to call api.* directly because queries cannot run until they exist. Selectors must return stable references or the UI will re-render forever.
- 03. [Workspace Isolation & Multi-Tenancy](https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/03-workspace-isolation-multi-tenancy.md) - 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.
- 04. [The Package Contract: Core, UI, Views](https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/04-the-package-contract-core-ui-views.md) - Three layers with iron boundaries. packages/core exports raw .ts/.tsx (no build step), contains every Zustand store and the API client, and forbids react-dom, localStorage, and process.env. packages/ui contains only pure Base UI + shadcn components and may never import @multica/core. packages/views contains all business screens and may never import next/* or react-router-dom; it receives routing via NavigationAdapter injected by the platform layer. Each consuming app supplies its own thin adapter (web/platform/navigation.tsx, desktop/.../navigation.tsx) and wraps its root with CoreProvider. This is why the identical page component works in both apps and survives HMR.
- 05. [Backend Safety Rails](https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/05-backend-safety-rails.md) - Three UUID rules prevent silent zero-row deletes and 204 responses that lie. Resource path params that can be human slugs (MUL-123) or UUIDs must go through loadIssueForUser / loadAgentForUser first; the resolved .ID is then used for all subsequent queries. Pure-UUID inputs from the wire use parseUUIDOrBadRequest which returns 400 on failure. Trusted round-trips (sqlc results, test fixtures) may use MustParseUUID which panics — a deliberate signal that unvalidated user input reached it. WS listeners (activity, autopilot, runtime sweeper) exist only to invalidate queries; they never mutate client state. Every handler that performs a write must ask “where did this UUID come from?”
- 06. [Navigation, Tabs & Route Isolation](https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/06-navigation-tabs-route-isolation.md) - useNavigation().push and AppLink are the only allowed navigation primitives in shared code. Desktop distinguishes three route categories: session routes (real tabs under WorkspaceRouteLayout), transition flows (create workspace, accept invite — rendered as WindowOverlay state, never real routes), and error states (never rendered; stale tabs are healed by dropping the tab group). The navigation adapter detects cross-workspace push and calls switchWorkspace instead of letting the memory router navigate inside the wrong tab. Web uses ordinary Next.js routes plus searchParams; desktop memory router + tab-store keep each workspace’s tabs isolated by construction.
- 07. [Agent Execution & Runtime Model](https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/07-agent-execution-runtime-model.md) - Assignee is polymorphic (assignee_type + assignee_id). An agent can be assigned exactly like a member; it claims work, streams progress, posts comments, and may create new issues. Two runtime surfaces exist: local daemon (CLI-managed, talks to desktop via daemon-ipc-bridge) and cloud runtimes. The daemon advertises available CLIs; the sweeper reaps dead runs. Every completed execution becomes a reusable skill stored under the workspace; future agents can invoke the same skill without re-learning. Health derivation and version detection live in packages/core/runtimes so both apps see identical status without duplication.
- 08. [Core Invariants & Safe Evolution Rules](https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/08-core-invariants-safe-evolution-rules.md) - The system stays coherent only while these hold: (1) server facts never live in Zustand, (2) WS events only invalidate, (3) every workspace-scoped query keys on wsId, (4) mutations are optimistic then invalidate, (5) core never touches react-dom or framework routers, (6) views never import next/* or react-router, (7) UUIDs from the wire are validated before any write query, (8) desktop destructive workspace ops call setCurrentWorkspace(null) before the mutation. Violating any produces drift, white-screens on older desktop builds, or cross-workspace data leaks. To change safely: add the Zod response schema in the same PR, write a test that feeds it malformed data, run make check, and only then consider the contract stable. These rules are the price of a desktop app that is always older than the server it talks to.
## Source File Index
- `AGENTS.md`
- `apps/desktop/src/renderer/src/App.tsx`
- `apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts`
- `apps/desktop/src/renderer/src/platform/navigation.tsx`
- `apps/desktop/src/renderer/src/stores/tab-store.ts`
- `apps/web/components/web-providers.tsx`
- `apps/web/platform/navigation.tsx`
- `CLAUDE.md`
- `Makefile`
- `packages/core/auth/store.ts`
- `packages/core/navigation/store.ts`
- `packages/core/pins/mutations.ts`
- `packages/core/pins/queries.ts`
- `packages/core/platform/core-provider.tsx`
- `packages/core/platform/index.ts`
- `packages/core/platform/workspace-storage.ts`
- `packages/core/query-client.ts`
- `packages/core/runtimes/cloud-runtime.ts`
- `packages/core/runtimes/derive-health.ts`
- `packages/core/runtimes/index.ts`
- `packages/core/runtimes/local-skills.ts`
- `packages/core/types/workspace.ts`
- `pnpm-workspace.yaml`
- `README.md`
- `server/cmd/server/listeners.go`
- `server/cmd/server/main.go`
- `server/cmd/server/runtime_sweeper.go`
- `server/internal/handler/agent.go`
- `server/internal/handler/handler.go`
- `server/internal/handler/scope_authorizer.go`
- `server/internal/util/pgx.go`
- `server/migrations/001_init.up.sql`
- `turbo.json`
---
## 01. The Mental Model
> The unifying picture: agents are teammates (not tools), multiplexing work like Multics for small human+AI teams. Workspace is the atomic unit of isolation and visibility. Server data lives only in TanStack Query; client UI state lives only in Zustand. WS events never write state — they only invalidate. The same business logic runs on web and desktop because three package layers plus thin platform adapters enforce the boundaries.
- Page Markdown: https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/01-the-mental-model.md
- Generated: 2026-05-21T23:30:56.113Z
### Source Files
- `README.md`
- `packages/core/platform/core-provider.tsx`
- `server/cmd/server/main.go`
- `server/migrations/001_init.up.sql`
- `pnpm-workspace.yaml`
- `turbo.json`
<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [README.md](README.md)
- [packages/core/platform/core-provider.tsx](packages/core/platform/core-provider.tsx)
- [packages/core/realtime/use-realtime-sync.ts](packages/core/realtime/use-realtime-sync.ts)
- [server/migrations/001_init.up.sql](server/migrations/001_init.up.sql)
- [pnpm-workspace.yaml](pnpm-workspace.yaml)
- [turbo.json](turbo.json)
- [packages/core/package.json](packages/core/package.json)
- [packages/views/package.json](packages/views/package.json)
- [packages/ui/package.json](packages/ui/package.json)
</details>
# The Mental Model
Multica is not a workflow automation tool with AI bolted on — it is a platform built from the ground up around one conviction: **agents are teammates, not tools**. The name is a deliberate nod to Multics, the 1960s operating system that introduced time-sharing so multiple users could share one machine as if each had it to themselves. Multica applies that same multiplexing idea to software teams: a small group of humans and autonomous agents can move like a much larger team by sharing the same task lifecycle, the same board, and the same runtime infrastructure.
Understanding how Multica works at depth requires holding five mental models in your head simultaneously: the agent-as-teammate model, the workspace-as-isolation-unit model, the server-state-in-TanStack-Query model, the client-state-in-Zustand model, and the WS-as-invalidation-only model. These five ideas are not independent design choices — they are mutually reinforcing constraints that make the system coherent across two completely different host platforms (web and Electron desktop) without duplicating business logic.
---
## Agents Are Teammates, Not Tools
In conventional AI tooling, an agent is a function you call and wait for. In Multica, an agent has a **profile**, an **assignee slot** on the issue board, an **activity timeline**, a **status** (`idle`, `working`, `blocked`, `error`, `offline`), and the ability to create issues, post comments, and report blockers autonomously — exactly the same affordances a human member has.
The schema makes this concrete. The `issue` table stores `assignee_type` as either `'member'` or `'agent'`, with a corresponding `assignee_id` UUID. Creator attribution follows the same pattern: `creator_type` + `creator_id`. There is no "AI output" column or separate "bot actions" table. Agents participate in the same data model as humans.
```sql
-- server/migrations/001_init.up.sql (lines 52–72)
CREATE TABLE issue (
assignee_type TEXT CHECK (assignee_type IN ('member', 'agent')),
assignee_id UUID,
creator_type TEXT NOT NULL CHECK (creator_type IN ('member', 'agent')),
creator_id UUID NOT NULL,
...
);
```
Agents also have their own table with first-class status tracking:
```sql
-- server/migrations/001_init.up.sql (lines 36–49)
CREATE TABLE agent (
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('idle', 'working', 'blocked', 'error', 'offline')),
runtime_mode TEXT NOT NULL CHECK (runtime_mode IN ('local', 'cloud')),
max_concurrent_tasks INT NOT NULL DEFAULT 1,
...
);
```
The Multics analogy is explicit in the README: "For decades, software teams have been single-threaded — one engineer, one task, one context switch at a time. AI agents change that equation."
Sources: [server/migrations/001_init.up.sql:36-72](), [README.md:41-51]()
---
## Workspace Is the Atomic Unit of Isolation
Every entity in the system — agents, issues, members, labels, skills, runtimes — is scoped to a `workspace_id`. The workspace table has a unique `slug` that drives URL routing and the `X-Workspace-Slug` header. There is no global namespace for issues or agents: `workspace_id` is a foreign key on every resource table, and `ON DELETE CASCADE` ensures resources are destroyed when the workspace is destroyed.
```sql
-- server/migrations/001_init.up.sql (lines 15-23)
CREATE TABLE workspace (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
...
);
-- Member join table: workspace_id is NOT NULL and cascades
CREATE TABLE member (
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
UNIQUE(workspace_id, user_id)
);
```
On the frontend, workspace identity is URL-driven. The `[workspaceSlug]` layout resolves the slug and calls `setCurrentWorkspace(slug, wsId)` on mount. The API client reads the slug from that singleton for the `X-Workspace-Slug` header. Every TanStack Query cache key for workspace-scoped data includes the `wsId`, making workspace switching automatic: when the active workspace ID changes, the cache key changes, the right data appears, and no manual invalidation is needed.
Sources: [server/migrations/001_init.up.sql:15-33](), [packages/core/platform/core-provider.tsx:51-54]()
---
## Server State Lives Only in TanStack Query
All data fetched from the API — issues, agents, members, workspace lists, inbox items, runtimes — lives exclusively in the TanStack Query cache. No copy of server data is written into Zustand stores. This is a hard architectural rule, not a preference.
The `CoreProvider` initializes the `QueryProvider` at the outermost layer of the component tree, making the query client available everywhere:
```tsx
// packages/core/platform/core-provider.tsx (lines 86-107)
const tree = (
<QueryProvider>
<AuthInitializer ...>
<WSProvider ...>
{children}
</WSProvider>
</AuthInitializer>
</QueryProvider>
);
```
The pnpm catalog pins `@tanstack/react-query` at `^5.96.2` and `zustand` at `^5.0.0` as first-class workspace-wide dependencies, making their role in the architecture explicit:
```yaml
# pnpm-workspace.yaml (lines 17-18)
zustand: "^5.0.0"
"@tanstack/react-query": "^5.96.2"
```
Sources: [packages/core/platform/core-provider.tsx:86-107](), [pnpm-workspace.yaml:17-18]()
---
## Client UI State Lives Only in Zustand
Zustand owns all client-side state: UI selections, filters, view modes, modal open/close state, tab layout (desktop), active chat session ID, and navigation history. All shared Zustand stores live in `packages/core/` — not in `packages/views/` or the apps — so both the web app and the Electron desktop app read from the same store instances.
The auth store and chat store are initialized as module-level singletons in `CoreProvider`, created once on first render and never recreated (Vite HMR preserves module-level state across hot reloads):
```tsx
// packages/core/platform/core-provider.tsx (lines 22-63)
let initialized = false;
let authStore: ReturnType<typeof createAuthStore>;
let chatStore: ReturnType<typeof createChatStore>;
function initCore(...) {
if (initialized) return;
// ... create api, authStore, chatStore
initialized = true;
}
```
The critical rule: **never duplicate server data into Zustand**. If it came from the API, it belongs in the Query cache. Copying it into a store creates two sources of truth that will drift.
Sources: [packages/core/platform/core-provider.tsx:22-63]()
---
## WS Events Never Write State — They Only Invalidate
This is the most commonly misunderstood rule in the architecture, and it is enforced with explicit documentation in the source.
When a WebSocket event arrives, `useRealtimeSync` (mounted inside `WSProvider`) dispatches one of two responses:
1. **Prefix-based invalidation (default):** The event type's prefix (e.g., `"agent"`, `"issue"`, `"task"`) maps to a `refreshMap` entry that calls `qc.invalidateQueries(...)`. A 100 ms debounce prevents rapid-fire refetches during bulk operations.
2. **Specific event handlers:** Fine-grained handlers for events like `issue:updated`, `issue:created`, and `workspace:deleted` apply surgical cache patches via `qc.setQueryData(...)` and then follow up with `qc.invalidateQueries(...)` to let the server remain authoritative.
```ts
// packages/core/realtime/use-realtime-sync.ts (lines 176-187)
/**
* Centralized WS -> store sync. Called once from WSProvider.
*
* Uses the "WS as invalidation signal + refetch" pattern:
* - onAny handler extracts event prefix and calls the matching store refresh
* - Debounce per-prefix prevents rapid-fire refetches (e.g. bulk issue updates)
* - Precise handlers only for side effects (toast, navigation, self-check)
*/
```
The comment at line 664 in `use-realtime-sync.ts` is explicit about why Zustand writes were removed from WS handlers:
```ts
// packages/core/realtime/use-realtime-sync.ts (lines 664-669)
// Single source of truth: the Query cache. No Zustand writes here — the
// earlier mirror caused a race where the cache and store disagreed
// during the invalidate → refetch window and the UI rendered duplicates.
```
On reconnect, the handler calls `invalidateWorkspaceScopedQueries(qc)` to recover any events missed while disconnected — again via invalidation, not state reconstruction.
Sources: [packages/core/realtime/use-realtime-sync.ts:176-374](), [packages/core/realtime/use-realtime-sync.ts:664-669](), [packages/core/realtime/use-realtime-sync.ts:929-941]()
---
## Three Package Layers + Thin Adapters = One Codebase, Two Platforms
The monorepo is organized into three shared packages plus thin platform adapters in each app. This is what allows the same business logic to run on both the Next.js web app and the Electron desktop app without duplication.
```
┌─────────────────────────────────────────────────────────┐
│ apps/web/ apps/desktop/ │
│ (Next.js) (Electron + react-router-dom) │
│ ┌──────────┐ ┌──────────┐ │
│ │platform/ │ │platform/ │ ← NavigationAdapter, │
│ │(next/*) │ │(RR-dom) │ cookie/token auth │
│ └────┬─────┘ └────┬─────┘ │
│ └────────┬──────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ packages/views/ (@multica/views) │ │
│ │ Business pages + components │ │
│ │ zero next/*, zero react-router-dom │ │
│ │ → imports @multica/core + @multica/ui │ │
│ └──────────────┬──────────────────────────────────┘ │
│ │ │
│ ┌──────────────┴────────────┐ ┌────────────────────┐ │
│ │ packages/core/ │ │ packages/ui/ │ │
│ │ (@multica/core) │ │ (@multica/ui) │ │
│ │ Stores, queries, API │ │ Atomic components │ │
│ │ client, WS, i18n │ │ zero business │ │
│ │ zero react-dom │ │ logic │ │
│ └───────────────────────────┘ └────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
```
**`packages/core` (`@multica/core`):** Zero `react-dom`, zero `localStorage` (uses `StorageAdapter`), zero `process.env`. All Zustand stores live here. This package contains the API client, the WebSocket client, the TanStack Query definitions (query keys and option factories), and `CoreProvider` — the root initializer that every app wraps around its tree.
**`packages/ui` (`@multica/ui`):** Zero `@multica/core` imports. Pure atomic UI components backed by Base UI (`@base-ui/react`) primitives, styled with Tailwind semantic tokens.
**`packages/views` (`@multica/views`):** Zero `next/*` imports, zero `react-router-dom` imports. Business pages and feature components that consume `@multica/core` and `@multica/ui`. Routing is done through a `NavigationAdapter` injected by the host app.
Each app's `platform/` directory is the only place where framework-specific APIs live. The web app's platform layer uses `next/navigation`; the desktop app's platform layer uses `react-router-dom`. Everything above that boundary is identical.
The pnpm workspace config establishes this structure:
```yaml
# pnpm-workspace.yaml (lines 1-3)
packages:
- "apps/*"
- "packages/*"
```
Turborepo enforces build ordering: `build` depends on `^build`, so `packages/core` always builds before `packages/views`, which always builds before `apps/web` or `apps/desktop`.
Sources: [pnpm-workspace.yaml:1-3](), [turbo.json:18-20](), [packages/views/package.json:54-61](), [packages/ui/package.json:1-10](), [packages/core/platform/core-provider.tsx:65-124]()
---
## How the Five Models Compose
At runtime, a single user action — say, an agent completing an issue — touches all five models at once:
1. **Agent-as-teammate:** The agent posts a comment and changes the issue status, just as a human member would. The `creator_type = 'agent'` field distinguishes the author, but the activity appears in the same timeline.
2. **Workspace isolation:** The server emits a `task:completed` WebSocket event scoped to the workspace. Only clients subscribed to that workspace receive it.
3. **WS invalidation:** `useRealtimeSync` receives `task:completed` and calls `qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.list(wsId) })` and several other workspace-keyed queries. It writes nothing to Zustand.
4. **TanStack Query refetch:** The invalidated queries refetch from the API. The issue detail page, the agent card, and the activity feed all update from the same cache keys — no duplication, no drift.
5. **Zustand client state:** The active filter selection, view mode, and tab layout are untouched by the server event. They remain exactly as the user set them.
Both the web browser and the Electron desktop window run this exact sequence through `packages/core/realtime/use-realtime-sync.ts` without any platform-specific branching — because the WS client, the QueryClient, and all the query keys live in `@multica/core`, shared equally by both apps.
Sources: [packages/core/realtime/use-realtime-sync.ts:292-328](), [packages/core/platform/core-provider.tsx:86-107](), [server/migrations/001_init.up.sql:36-72]()
---
## Summary
Multica's mental model is a set of five mutually reinforcing constraints: agents use the same data model as humans; workspaces are the only isolation boundary; all server data lives in TanStack Query; all client UI state lives in Zustand; WebSocket events never write state directly but always invalidate queries. These constraints are enforced structurally by the three-layer package architecture (`core` → `views` → `apps`) and its thin platform adapters. The result is a single shared codebase that runs identically on web and Electron desktop, where human and AI teammates are genuinely interchangeable participants on the board.
Sources: [packages/core/realtime/use-realtime-sync.ts:176-188](), [packages/core/platform/core-provider.tsx:21-63]()
---
## 02. The State Ownership Contract
> React Query owns every server fact (issues, agents, members, inbox, workspace list). Zustand owns only ephemeral client facts (current tab, filters, drafts, modal open/closed). Copying a query result into a store creates two drifting sources of truth. WS listeners only call queryClient.invalidateQueries; they never call store setters. Auth and workspace stores are the sole exceptions allowed to call api.* directly because queries cannot run until they exist. Selectors must return stable references or the UI will re-render forever.
- Page Markdown: https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/02-the-state-ownership-contract.md
- Generated: 2026-05-21T23:20:30.947Z
### Source Files
- `packages/core/query-client.ts`
- `packages/core/auth/store.ts`
- `packages/core/platform/core-provider.tsx`
- `packages/core/pins/queries.ts`
- `packages/core/pins/mutations.ts`
- `server/cmd/server/listeners.go`
<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [packages/core/query-client.ts](packages/core/query-client.ts)
- [packages/core/auth/store.ts](packages/core/auth/store.ts)
- [packages/core/platform/core-provider.tsx](packages/core/platform/core-provider.tsx)
- [packages/core/pins/queries.ts](packages/core/pins/queries.ts)
- [packages/core/pins/mutations.ts](packages/core/pins/mutations.ts)
- [server/cmd/server/listeners.go](server/cmd/server/listeners.go)
- [packages/core/realtime/use-realtime-sync.ts](packages/core/realtime/use-realtime-sync.ts)
- [packages/core/platform/workspace-storage.ts](packages/core/platform/workspace-storage.ts)
- [apps/web/app/[workspaceSlug]/layout.tsx](apps/web/app/[workspaceSlug]/layout.tsx)
- [packages/core/workspace/queries.ts](packages/core/workspace/queries.ts)
- [packages/core/provider.tsx](packages/core/provider.tsx)
- [packages/core/realtime/provider.tsx](packages/core/realtime/provider.tsx)
- [packages/core/modals/store.ts](packages/core/modals/store.ts)
- [CLAUDE.md](CLAUDE.md)
- [AGENTS.md](AGENTS.md)
- [packages/core/issues/queries.ts](packages/core/issues/queries.ts)
</details>
# The State Ownership Contract
React Query owns every server fact (issues, agents, members, inbox, workspace list, pins, runtimes, etc.). Zustand owns only ephemeral client facts (current tab, filters, drafts, modal open/closed, active chat session pointer, view selections). Copying a query result into a store creates two drifting sources of truth. WS listeners only call `queryClient.invalidateQueries`; they never call store setters. Auth and workspace bootstrap are the sole exceptions allowed to call `api.*` directly because queries cannot run until those facts exist. Selectors must return stable references or the UI will re-render forever.
This contract is the central invariant that keeps the monorepo's dual-app (Next.js + Electron) architecture coherent. It is explicitly stated in the repository guidelines and enforced by code structure, query-key factories, and the realtime sync layer.
## State Ownership Principles
The architecture enforces a strict split between server state and client state. Mixing them is the most common way to break the system.
- **TanStack Query owns all server state.** Issues, users, workspaces, inbox — anything fetched from the API lives in the Query cache. WS events keep it fresh via invalidation; no polling, no `staleTime` workarounds.
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so both apps share them.
- **Never duplicate server data into Zustand.** If it came from the API, it belongs in the Query cache. Copying it into a store creates two sources of truth and they will drift.
- **WS events invalidate queries — they never write to stores directly.** This keeps the cache as the single source of truth and avoids race conditions.
- **Auth and workspace bootstrap stores are the only stores allowed to call `api.*` directly**, because they manage critical state that must exist before queries can run.
**Sources:** [CLAUDE.md:52-80](), [AGENTS.md:25-28]()
## Server State: TanStack Query
All server-derived facts are modeled as React Query cache entries. Query keys are defined in per-domain `queries.ts` files (e.g., `issueKeys`, `workspaceKeys`, `pinKeys`).
The `QueryClient` is configured with `staleTime: Infinity`:
```ts:1:17:packages/core/query-client.ts
import { QueryClient } from "@tanstack/react-query";
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: 1,
},
mutations: {
retry: false,
},
},
});
}
```
Once a workspace-scoped list (issues, members, agents, etc.) is fetched, it remains authoritative until an explicit `invalidateQueries` occurs. Workspace switches are automatic because every key includes `wsId`.
**Sources:** [packages/core/query-client.ts:1-17](), [packages/core/workspace/queries.ts:1-40](), [packages/core/issues/queries.ts:1-30]()
## Client State: Zustand Stores
Zustand stores contain only what the server never knew and cannot invalidate:
- Modal open/closed flags and transient payloads
- Draft text and local form state
- View filters, sort orders, column visibility
- Current tab / active chat session pointer
- Selection state (e.g., multi-issue checkbox state)
Example — the modal store owns nothing the server owns:
```ts:1:22:packages/core/modals/store.ts
"use client";
import { create } from "zustand";
type ModalType = ... | null;
interface ModalStore {
modal: ModalType;
data: Record<string, unknown> | null;
open: (modal: NonNullable<ModalType>, data?: ...) => void;
close: () => void;
}
export const useModalStore = create<ModalStore>((set) => ({
modal: null,
data: null,
open: (modal, data = null) => set({ modal, data }),
close: () => set({ modal: null, data: null }),
}));
```
All stores live under `packages/core/*/store.ts` or `packages/core/*/stores/`. None import server response types as their primary data.
**Sources:** [packages/core/modals/store.ts:1-22](), [packages/core/auth/store.ts:1-138](), [packages/core/issues/stores/*.ts]()
## The Two Bootstrap Exceptions
Two narrow paths are allowed to call the API before the Query cache exists:
1. **Auth store** (`packages/core/auth/store.ts`) — `initialize`, `verifyCode`, `loginWith*`, `refreshMe`, `logout`. It alone may call `api.getMe()`, `api.verifyCode()`, etc., and then calls `setCurrentWorkspace(null, null)` on logout.
2. **Workspace identity singleton** — `setCurrentWorkspace(slug, id)` is called from `WorkspaceLayout` (and desktop equivalent) after a successful `useQuery(workspaceBySlugOptions(...))`. Only the identity pointer is stored; the full `Workspace` object remains in the RQ cache.
```ts:56:59:apps/web/app/[workspaceSlug]/layout.tsx
if (workspace) {
setCurrentWorkspace(workspaceSlug, workspace.id);
}
```
The layout itself reads the workspace via React Query:
```ts:48:51:apps/web/app/[workspaceSlug]/layout.tsx
const { data: workspace, isFetched: listFetched } = useQuery({
...workspaceBySlugOptions(workspaceSlug),
enabled: !!user,
});
```
**Sources:** [packages/core/auth/store.ts:1-138](), [packages/core/platform/workspace-storage.ts:1-117](), [apps/web/app/[workspaceSlug]/layout.tsx:1-80]()
## WebSocket Events: Invalidation Only
The Go server (`listeners.go`) emits events on every mutating change (issue created, member added, task completed, etc.). These are broadcast to the relevant workspace room or user.
On the client, `useRealtimeSync` (wired once from `WSProvider`) translates every event into one or more `qc.invalidateQueries` calls — never `store.set(...)` for domain data.
```ts:152:165:packages/core/realtime/use-realtime-sync.ts
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
...
```
The single narrow exception is chat session deletion, where the active pointer in the chat store is cleared so the UI does not point at a vanished session. The actual message and session lists stay in the query cache.
```ts:878:882:packages/core/realtime/use-realtime-sync.ts
const chatState = useChatStore.getState?.();
if (chatState && chatState.activeSessionId === payload.chat_session_id) {
chatState.setActiveSession(null);
}
```
**Sources:** [server/cmd/server/listeners.go:1-194](), [packages/core/realtime/use-realtime-sync.ts:100-320](), [packages/core/realtime/provider.tsx:1-80]()
## Mutations: Optimistic Updates Live in the Cache
Mutations follow the same contract. They perform optimistic `setQueryData` on the relevant query key, roll back on error, and always `invalidateQueries` on settle so the server is the final source of truth.
See `useCreatePin`, `useDeletePin`, and `useReorderPins` in `pins/mutations.ts` for the canonical pattern.
**Sources:** [packages/core/pins/mutations.ts:1-69](), [packages/core/runtimes/mutations.ts:1-35]()
## Workspace Identity: The Thin Pointer
`workspace-storage.ts` is a module-level singleton, not a full Zustand store. It holds only `(slug, wsId)` and powers:
- `X-Workspace-Slug` header derivation in the API client
- Workspace-scoped storage namespacing
- WS reconnect on slug change
- Rehydration of persisted client stores
It never holds lists or objects that belong in query cache.
**Sources:** [packages/core/platform/workspace-storage.ts:1-117]()
## Stable Selectors and Common Footguns
Because Zustand stores are used from many components, selectors must return stable references:
> Selectors must return stable references. Returning a freshly built object or array on every call (e.g. `s => ({ a: s.a, b: s.b })` or `s => s.items.map(...)`) triggers infinite re-renders. Either select primitives separately or use shallow comparison.
**Sources:** [CLAUDE.md:70-73]()
## Failure Modes If the Contract Is Violated
- **Drift**: a WS event updates the server; the copied Zustand value stays stale until hard refresh.
- **Lost updates**: optimistic mutation writes to a store; later invalidate does not touch it.
- **Re-render storms**: derived objects recreated in selectors on every store change.
- **Workspace leakage**: persisted drafts or filters written under the wrong slug because `setCurrentWorkspace` was bypassed.
- **Auth deadlocks**: queries attempted before the auth store has called `api.getMe()`.
The design eliminates these by construction: the query cache is the only place server facts live, and the realtime layer never writes anything else.
## Summary
The State Ownership Contract is the single most important architectural rule in the repository. Follow it and the system stays coherent across web, desktop, workspace switches, and realtime updates. Violate it and you will create exactly the classes of bugs the architecture was built to prevent.
**Sources:** [CLAUDE.md:52-80](), [packages/core/realtime/use-realtime-sync.ts:138-165]()
---
## 03. 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.
- Page Markdown: https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/03-workspace-isolation-multi-tenancy.md
- Generated: 2026-05-21T23:19:51.732Z
### 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]()
---
## 04. The Package Contract: Core, UI, Views
> Three layers with iron boundaries. packages/core exports raw .ts/.tsx (no build step), contains every Zustand store and the API client, and forbids react-dom, localStorage, and process.env. packages/ui contains only pure Base UI + shadcn components and may never import @multica/core. packages/views contains all business screens and may never import next/* or react-router-dom; it receives routing via NavigationAdapter injected by the platform layer. Each consuming app supplies its own thin adapter (web/platform/navigation.tsx, desktop/.../navigation.tsx) and wraps its root with CoreProvider. This is why the identical page component works in both apps and survives HMR.
- Page Markdown: https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/04-the-package-contract-core-ui-views.md
- Generated: 2026-05-21T23:20:32.763Z
### Source Files
- `packages/core/platform/index.ts`
- `packages/core/platform/core-provider.tsx`
- `apps/web/platform/navigation.tsx`
- `apps/desktop/src/renderer/src/platform/navigation.tsx`
- `apps/web/components/web-providers.tsx`
- `apps/desktop/src/renderer/src/App.tsx`
- `pnpm-workspace.yaml`
<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [packages/core/platform/index.ts](packages/core/platform/index.ts)
- [packages/core/platform/core-provider.tsx](packages/core/platform/core-provider.tsx)
- [packages/core/platform/types.ts](packages/core/platform/types.ts)
- [packages/core/platform/storage.ts](packages/core/platform/storage.ts)
- [packages/core/package.json](packages/core/package.json)
- [packages/core/auth/index.ts](packages/core/auth/index.ts)
- [packages/views/navigation/types.ts](packages/views/navigation/types.ts)
- [packages/views/navigation/context.tsx](packages/views/navigation/context.tsx)
- [packages/views/navigation/index.ts](packages/views/navigation/index.ts)
- [packages/views/package.json](packages/views/package.json)
- [packages/views/platform/index.ts](packages/views/platform/index.ts)
- [apps/web/platform/navigation.tsx](apps/web/platform/navigation.tsx)
- [apps/desktop/src/renderer/src/platform/navigation.tsx](apps/desktop/src/renderer/src/platform/navigation.tsx)
- [apps/web/components/web-providers.tsx](apps/web/components/web-providers.tsx)
- [apps/desktop/src/renderer/src/App.tsx](apps/desktop/src/renderer/src/App.tsx)
- [apps/web/app/layout.tsx](apps/web/app/layout.tsx)
</details>
# The Package Contract: Core, UI, Views
Three packages with iron boundaries let the same business screens, modals, and navigation logic run unchanged in the Next.js web app and the Electron desktop app. `packages/core` owns all server state (React Query), client state (Zustand), the API client, and realtime wiring. It ships as raw TypeScript with no build step. `packages/ui` contains only pure Base UI + shadcn components and never touches `@multica/core`. `packages/views` owns every business page and complex component; it depends on core and ui but is forbidden from importing `next/*` or `react-router-dom`. Routing reaches views through a `NavigationAdapter` interface that each app implements in a one-file platform layer. Both apps wrap their root with the same `CoreProvider`. The result is zero duplication, instant HMR across packages, and a single source of truth for every screen.
## The Internal Packages Pattern
All three packages declare `"type": "module"` and map their `exports` directly to `.ts` and `.tsx` source files. No `dist/`, no `tsc` emit, no pre-bundling. The bundler of the consuming app (Vite in desktop, Next.js compiler in web) type-checks and transforms the files on the fly. This produces zero-config HMR: editing a view component or a core store immediately updates both running apps.
```json
// packages/core/package.json:11-96
"exports": {
"./auth": "./auth/index.ts",
"./issues/stores": "./issues/stores/index.ts",
...
"./platform": "./platform/index.ts"
}
```
The same pattern appears in `packages/ui/package.json:10-23` and `packages/views/package.json:11-53`. Because the exports point at source, any app can reach a deep file with a stable subpath import such as `@multica/views/issues/components` or `@multica/core/issues/stores/selection-store`.
Sources: [packages/core/package.json:11-96](), [packages/views/package.json:11-53](), [packages/ui/package.json:10-23]()
## packages/core – The Single Source of Truth
`packages/core` is the only place that may:
- Call the API (`ApiClient`, `api.*`)
- Own Zustand stores (auth, chat, issues/*, agents/*, projects, squads, modals, navigation, onboarding, feedback, runtimes, etc.)
- Own React Query cache and the `QueryClient`
- Manage realtime WebSocket connection
- Define `StorageAdapter`, `LocaleAdapter`, and other platform-neutral contracts
It is strictly forbidden from importing `react-dom`, reading or writing `localStorage` directly, referencing `process.env`, or importing anything from `@multica/ui` or `@multica/views`.
Creation and registration of the critical auth and chat stores happens once inside `CoreProvider`:
```tsx
// packages/core/platform/core-provider.tsx:56-60
authStore = createAuthStore({ api, storage, onLogin, onLogout, cookieAuth });
registerAuthStore(authStore);
chatStore = createChatStore({ storage });
registerChatStore(chatStore);
```
The `register*Store` + Proxy accessor pattern (see `auth/index.ts:16-40`) lets the rest of the codebase consume `useAuthStore` without ever importing the concrete factory. The same module-level singletons survive Vite HMR because they live outside any React component tree.
Platform-specific concerns reach core only through injected adapters (`StorageAdapter`, `LocaleAdapter`, `onLogin`/`onLogout` callbacks, `ClientIdentity`). The default storage implementation (`defaultStorage`) is a thin SSR-safe wrapper; no file in `packages/core` (outside tests) ever touches the global `localStorage` or `process.env`.
Sources: [packages/core/platform/core-provider.tsx:21-63](), [packages/core/auth/index.ts:16-40](), [packages/core/platform/storage.ts:4-13](), [packages/core/platform/types.ts:20-43]()
## packages/ui – Pure Presentational Layer
`packages/ui` exports only:
- shadcn/Base UI components (`components/ui/*`, `components/common/*`)
- Markdown rendering utilities
- Layout primitives, hooks that do not know about business state
- CSS tokens and base styles
A single comment in the entire tree even mentions `@multica/core`; there are zero runtime imports. This guarantees that a designer or a different product can consume the design system in isolation.
Sources: [packages/ui/package.json:10-23](), grep verification across `packages/ui` for `@multica/core` (only a comment at `submit-button.tsx:22`)
## packages/views – Business Screens and Shared Logic
All real product surfaces live here: `dashboard`, `issues/components`, `agents`, `inbox`, `chat`, `settings`, `onboarding`, `modals/*`, `my-issues`, `squads`, `workspace/*` pages, etc. Every one of these files may import from `@multica/core` and `@multica/ui`, but never from `next/*` or `react-router-dom`.
Routing is supplied by the `NavigationProvider` / `useNavigation` pair that views itself owns:
```ts
// packages/views/navigation/types.ts:1-29
export interface NavigationAdapter {
push(path: string): void;
replace(path: string): void;
back(): void;
pathname: string;
searchParams: URLSearchParams;
openInNewTab?(...): void; // desktop-only extension
getShareableUrl(path: string): string;
prefetch?(path: string): void;
}
```
The context (`context.tsx:9-49`) wraps the adapter so that every `AppLink`, command-palette item, and post-mutation redirect calls the same `useNavigation().push` regardless of platform.
Sources: [packages/views/navigation/types.ts:1-29](), [packages/views/navigation/context.tsx:9-49](), [packages/views/navigation/index.ts:1-7]()
## Platform Adapters – The Only Framework Glue
Each app supplies a one-file adapter that implements `NavigationAdapter` and then renders the shared `NavigationProvider`.
**Web** (`apps/web/platform/navigation.tsx:19-33`) reads `useRouter`, `usePathname`, `useSearchParams` from `next/navigation` and forwards them:
```tsx
const adapter: NavigationAdapter = {
push: router.push,
replace: router.replace,
...
getShareableUrl: (path) => window.location.origin + path,
prefetch: router.prefetch,
};
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
```
**Desktop** (`apps/desktop/src/renderer/src/platform/navigation.tsx:189-240` and `270-308`) is more involved: it intercepts cross-workspace pushes, pinned-tab rules, and overlay transitions (`/workspaces/new`, `/invite/*`) before delegating to the per-tab `DataRouter`. The two navigation providers (`DesktopNavigationProvider` for shell, `TabNavigationProvider` for tab content) keep the same interface that views components already understand.
Both adapters are wrapped after `CoreProvider` at the root:
- Web: `WebProviders` → `CoreProvider` + `WebNavigationProvider` (layout.tsx:139)
- Desktop: `App` → `CoreProvider` + desktop shell that mounts `DesktopNavigationProvider` / `TabNavigationProvider`
Because the adapter is the only place that knows the concrete router, a single `<NewWorkspacePage>` component can be rendered both as a Next.js route (`apps/web/app/(auth)/workspaces/new/page.tsx:9`) and as a `WindowOverlay` child on desktop (`window-overlay.tsx:51`).
Sources: [apps/web/platform/navigation.tsx:19-35](), [apps/desktop/src/renderer/src/platform/navigation.tsx:189-240,270-308](), [apps/web/components/web-providers.tsx:65-91](), [apps/desktop/src/renderer/src/App.tsx:327-337]()
## Dependency Direction and Invariants
```
apps/web apps/desktop
\ /
v v
packages/views (business screens, NavigationProvider, AppLink)
| |
+---------------+
|
+--------+--------+
| |
packages/core packages/ui
(headless + state) (pure components)
```
- `core` and `ui` are peers; neither may import the other.
- `views` may import both.
- Only the two thin platform layers in `apps/*` may import framework routing packages.
- All Zustand stores that hold workspace-scoped or business data live in `core`; the single narrow exception (`views/search/search-store.ts`) is a pure UI chrome flag for the command palette and is never used for server data or cross-page coordination.
Violating any arrow immediately breaks one of the two apps or destroys HMR.
Sources: [packages/core/platform/core-provider.tsx:78-81](), package.json exports maps, import greps across `packages/ui` and `packages/views`
## Why the Design Survives HMR and Dual Apps
1. Module-level singletons in core are created exactly once on first `CoreProvider` render and are preserved by Vite's module graph.
2. React Query owns server state; WS events only invalidate queries.
3. Zustand owns ephemeral client state (filters, drafts, selection, modals) and is registered through the same boot path in both apps.
4. Navigation is a pure interface; the adapter can be swapped without touching a single business component.
If the contract were relaxed (for example, allowing `views` to import `next/navigation` directly), the desktop app would either crash on missing hooks or require heavy duplication and conditional imports—the exact problems the three-layer design was built to eliminate.
Sources: [packages/core/platform/core-provider.tsx:21-63](), [apps/web/app/layout.tsx:139-141](), [apps/desktop/src/renderer/src/App.tsx:284-344]()
## Summary
The package contract is the mechanism that lets every screen, modal, and navigation action be written once in `packages/views`, backed by state and API logic that lives only in `packages/core`, rendered with components that live only in `packages/ui`, and executed in two completely different runtime environments through two one-file adapters. The boundaries are not suggestions; they are the reason the product can ship a desktop app that feels native while sharing >95 % of its UI and logic with the web experience, and the reason changes to a shared page survive HMR in both targets without coordination.
Sources: [packages/core/platform/core-provider.tsx:21-124]()
---
## 05. Backend Safety Rails
> Three UUID rules prevent silent zero-row deletes and 204 responses that lie. Resource path params that can be human slugs (MUL-123) or UUIDs must go through loadIssueForUser / loadAgentForUser first; the resolved .ID is then used for all subsequent queries. Pure-UUID inputs from the wire use parseUUIDOrBadRequest which returns 400 on failure. Trusted round-trips (sqlc results, test fixtures) may use MustParseUUID which panics — a deliberate signal that unvalidated user input reached it. WS listeners (activity, autopilot, runtime sweeper) exist only to invalidate queries; they never mutate client state. Every handler that performs a write must ask “where did this UUID come from?”
- Page Markdown: https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/05-backend-safety-rails.md
- Generated: 2026-05-21T23:22:59.385Z
### Source Files
- `server/internal/handler/handler.go`
- `server/internal/util/pgx.go`
- `server/cmd/server/main.go`
- `server/cmd/server/listeners.go`
- `server/cmd/server/runtime_sweeper.go`
- `server/internal/handler/agent.go`
<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [server/internal/handler/handler.go](server/internal/handler/handler.go)
- [server/internal/util/pgx.go](server/internal/util/pgx.go)
- [server/internal/util/pgx_test.go](server/internal/util/pgx_test.go)
- [CLAUDE.md](CLAUDE.md)
- [server/cmd/server/main.go](server/cmd/server/main.go)
- [server/cmd/server/listeners.go](server/cmd/server/listeners.go)
- [server/cmd/server/runtime_sweeper.go](server/cmd/server/runtime_sweeper.go)
- [server/internal/handler/agent.go](server/internal/handler/agent.go)
- [server/internal/handler/issue.go](server/internal/handler/issue.go)
- [server/internal/handler/daemon.go](server/internal/handler/daemon.go)
- [packages/core/realtime/use-realtime-sync.ts](packages/core/realtime/use-realtime-sync.ts)
</details>
# Backend Safety Rails
Three UUID handling rules in the Go backend prevent silent zero-row `DELETE` and `UPDATE` operations that return 204 success while performing no work. The rules distinguish three sources of identifiers and force every write path to obtain a validated, canonical `pgtype.UUID` before calling any `Queries.Delete*` or `Queries.Update*`. Resource path parameters that accept both human-readable slugs (e.g., `MUL-123`) and UUIDs must travel through a dedicated loader (`loadIssueForUser`, `loadAgentForUser`, `loadSkillForUser`, or `requireDaemonRuntimeAccess`). Pure-UUID values arriving on the wire use `parseUUIDOrBadRequest`. Trusted round-trips from sqlc or test fixtures use the panicking `parseUUID` (MustParseUUID) variant. WebSocket listeners for activity, autopilot, and the runtime sweeper exist only to publish invalidation signals; they never mutate client-side server state.
The contract is summarized in the project guidelines and enforced at the handler layer so that a malformed or slug identifier can never silently become a zero-valued UUID that matches zero rows.
## The Historical Failure
Before the current `ParseUUID` implementation, the utility silently returned a valid zero-valued `pgtype.UUID` on any input it could not parse. A `DELETE` using that zero UUID would execute without error, the `CommandTag` would report zero rows affected, yet the handler would still write 204. Issue #1661 was the concrete case that made the pattern intolerable.
```go
// server/internal/util/pgx.go:11-28
// ParseUUID parses s into a pgtype.UUID. Invalid input returns an error
// instead of a zero-valued UUID — silently dropping bad input has caused
// data-loss bugs (e.g. DELETE matching no rows, returning 204 success).
func ParseUUID(s string) (pgtype.UUID, error) { ... }
```
The test suite now explicitly guards the invariant:
```go
// server/internal/util/pgx_test.go:25-28
// Returning a valid zero-UUID was the root cause of #1661.
if u.Valid {
t.Fatalf("expected u.Valid = false for %q, got true", s)
}
```
Sources: [server/internal/util/pgx.go:11-28](), [server/internal/util/pgx_test.go:25-28]()
## The Three Rules
Every handler that touches the database for a write must answer one question: "Where did this UUID come from?"
| Identifier Source | Required Gate | Helper | Failure Behavior | Typical Call Sites |
|-------------------|---------------|--------|------------------|--------------------|
| Resource path that may be human slug (`MUL-123`) or UUID (issues, agents, skills, daemon runtimes) | Dedicated loader that resolves to an entity first | `loadIssueForUser`, `loadAgentForUser`, `loadSkillForUser`, `requireDaemonRuntimeAccess` | 404 (or 400 for workspace) | `DeleteIssue`, `UpdateAgent`, `DeleteSkill`, task enqueue paths |
| Pure-UUID arriving on the wire (request body, header, URL param that is always a UUID) | Explicit validation at the boundary | `parseUUIDOrBadRequest(w, s, fieldName)` | 400 Bad Request, `ok=false` | `runtime_id`, `task_id`, inbox item IDs, most body fields |
| Trusted round-trip (sqlc result passed back inside the same request, test fixtures) | Panicking helper (signals bug if raw user input reaches it) | `parseUUID(s)` / `util.MustParseUUID` | panic (recovered to 500) | `existing.ID` after a loader, IDs from `Queries.Get*` inside the handler |
The authoritative statement of the rules lives in the developer conventions:
```markdown
# CLAUDE.md:171-180
- Resource path params that accept either a UUID or a human-readable identifier MUST be resolved through the dedicated loader...
- Pure-UUID inputs from request boundaries MUST be validated with `parseUUIDOrBadRequest`...
- Trusted UUID round-trips use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input...
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?"
```
Sources: [CLAUDE.md:171-180](), [server/internal/handler/handler.go:167-196]()
## Loaders in Practice
`loadIssueForUser` demonstrates the dual-format path required for human-readable identifiers:
```go
// server/internal/handler/handler.go:466-505
func (h *Handler) loadIssueForUser(...) (db.Issue, bool) {
...
if issue, ok := h.resolveIssueByIdentifier(...); ok { return issue, true }
issueUUID, err := util.ParseUUID(issueID) // only after slug attempt fails
...
issue, err := h.Queries.GetIssueInWorkspace(..., issueUUID, ...)
return issue, true
}
```
After the loader succeeds, every subsequent write uses the resolved `issue.ID`:
```go
// server/internal/handler/issue.go:2382-2402
func (h *Handler) DeleteIssue(...) {
id := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, id)
if !ok { return }
h.TaskService.CancelTasksForIssue(..., issue.ID)
h.Queries.FailAutopilotRunsByIssue(..., issue.ID)
...
err := h.Queries.DeleteIssue(..., issue.ID) // never the raw string
}
```
`loadAgentForUser` and `requireDaemonRuntimeAccess` follow the same shape, each calling `parseUUIDOrBadRequest` internally so that a bad UUID never reaches a query.
Sources: [server/internal/handler/handler.go:466-505](), [server/internal/handler/handler.go:574-602](), [server/internal/handler/issue.go:2382-2402](), [server/internal/handler/daemon.go:72-100]()
## Trusted Round-Trip Path
Inside a handler, once an entity has been obtained from the database or a loader, its ID may be passed to another query using the thin wrapper:
```go
// server/internal/handler/handler.go:167-179
// parseUUID is intentionally the panicking variant...
// A panic here means an unguarded user-input string slipped in.
func parseUUID(s string) pgtype.UUID { return util.MustParseUUID(s) }
```
The panic is deliberate: it turns a latent bug into an immediate 500 during development and CI rather than a silent no-op in production.
## Realtime Listeners and Invalidation Contract
Activity, autopilot, and runtime-sweeper listeners publish events (e.g., `daemon:register` with `action: "stale_sweep"`) so that connected clients know to refresh data. The sweeper itself mutates the database; the published event is only a notification.
```go
// server/cmd/server/runtime_sweeper.go:150-165
for wsID := range workspaces {
bus.Publish(events.Event{
Type: protocol.EventDaemonRegister,
...
Payload: map[string]any{"action": "stale_sweep"},
})
}
```
On the client, `useRealtimeSync` reacts to these events exclusively by calling `queryClient.invalidateQueries`:
```ts
// packages/core/realtime/use-realtime-sync.ts:271
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) })
```
No server-derived data is ever written directly into a Zustand store from a WebSocket handler. Zustand owns only client state (current workspace, filters, drafts, modal visibility). React Query owns all server state; WS events keep it fresh via invalidation.
Registration of the listeners occurs once in `main.go` after the event bus and broadcaster are created.
Sources: [server/cmd/server/runtime_sweeper.go:150-165](), [packages/core/realtime/use-realtime-sync.ts:159-182](), [server/cmd/server/main.go:245-320]()
## Summary
The safety rails are a narrow, enforceable contract at the HTTP handler boundary. By forcing every write path to declare the provenance of its UUIDs, the system eliminates an entire class of "delete succeeded but actually did nothing" bugs. Loaders, `parseUUIDOrBadRequest`, and the panicking trusted helper each correspond to one source of truth for an identifier. WebSocket listeners remain pure invalidation signals, preserving the single-source-of-truth guarantee between React Query and the database. The pattern is documented in CLAUDE.md and verified by the test that would have caught #1661.
Sources: [CLAUDE.md:171-180]()
---
## 06. Navigation, Tabs & Route Isolation
> useNavigation().push and AppLink are the only allowed navigation primitives in shared code. Desktop distinguishes three route categories: session routes (real tabs under WorkspaceRouteLayout), transition flows (create workspace, accept invite — rendered as WindowOverlay state, never real routes), and error states (never rendered; stale tabs are healed by dropping the tab group). The navigation adapter detects cross-workspace push and calls switchWorkspace instead of letting the memory router navigate inside the wrong tab. Web uses ordinary Next.js routes plus searchParams; desktop memory router + tab-store keep each workspace’s tabs isolated by construction.
- Page Markdown: https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/06-navigation-tabs-route-isolation.md
- Generated: 2026-05-21T23:20:20.794Z
### Source Files
- `packages/core/navigation/store.ts`
- `apps/desktop/src/renderer/src/stores/tab-store.ts`
- `apps/desktop/src/renderer/src/platform/navigation.tsx`
- `apps/web/platform/navigation.tsx`
- `packages/core/platform/core-provider.tsx`
<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [packages/core/navigation/store.ts](packages/core/navigation/store.ts)
- [apps/desktop/src/renderer/src/stores/tab-store.ts](apps/desktop/src/renderer/src/stores/tab-store.ts)
- [apps/desktop/src/renderer/src/platform/navigation.tsx](apps/desktop/src/renderer/src/platform/navigation.tsx)
- [apps/web/platform/navigation.tsx](apps/web/platform/navigation.tsx)
- [packages/core/platform/core-provider.tsx](packages/core/platform/core-provider.tsx)
- [packages/views/navigation/index.ts](packages/views/navigation/index.ts)
- [packages/views/navigation/context.tsx](packages/views/navigation/context.tsx)
- [packages/views/navigation/app-link.tsx](packages/views/navigation/app-link.tsx)
- [packages/views/navigation/types.ts](packages/views/navigation/types.ts)
- [apps/desktop/src/renderer/src/components/workspace-route-layout.tsx](apps/desktop/src/renderer/src/components/workspace-route-layout.tsx)
- [apps/desktop/src/renderer/src/routes.tsx](apps/desktop/src/renderer/src/routes.tsx)
- [apps/desktop/src/renderer/src/stores/window-overlay-store.ts](apps/desktop/src/renderer/src/stores/window-overlay-store.ts)
- [apps/desktop/src/renderer/src/components/tab-content.tsx](apps/desktop/src/renderer/src/components/tab-content.tsx)
- [apps/desktop/src/renderer/src/App.tsx](apps/desktop/src/renderer/src/App.tsx)
- [apps/web/app/[workspaceSlug]/layout.tsx](apps/web/app/[workspaceSlug]/layout.tsx)
- [packages/core/platform/workspace-storage.ts](packages/core/platform/workspace-storage.ts)
- [packages/core/paths/reserved-slugs.ts](packages/core/paths/reserved-slugs.ts)
</details>
# Navigation, Tabs & Route Isolation
`useNavigation().push()` and `<AppLink>` are the only navigation primitives permitted in shared business code (`packages/views/`). All platform-specific routing (Next.js App Router vs. react-router memory routers) is hidden behind the `NavigationAdapter` interface supplied by each app's `NavigationProvider`. This boundary keeps view components portable across web and desktop while allowing desktop to solve a hard problem: multiple independent workspaces must coexist in one window with fully isolated tab state, yet a single `push()` call from a shared component must never leak a tab across workspace boundaries or persist a transition flow as a real tab.
The desktop implementation distinguishes three route categories by construction. Session routes live under per-tab memory routers inside `WorkspaceRouteLayout`. Transition flows (`/workspaces/new`, `/invite/*`, onboarding, invitations list) are intercepted and rendered as `WindowOverlay` state, never as routes. Error states for stale workspace slugs are never rendered as pages; `WorkspaceRouteLayout` heals them by dropping the entire tab group. The navigation adapter performs the classification on every `push`/`replace` before any router sees the path. Web, by contrast, uses ordinary Next.js file-system routes plus `searchParams`; a missing workspace slug produces an explicit `NoAccessPage` because the URL is user-visible and shareable.
## Navigation Primitives and the Adapter Contract
Shared code may only call:
- `const { push, replace, back, pathname, searchParams, openInNewTab, getShareableUrl, prefetch } = useNavigation();`
- `<AppLink href="...">` (which calls `push` and wires meta/ctrl-click to `openInNewTab` when available)
The `NavigationProvider` (from `@multica/views/navigation`) wraps the platform adapter and adds a React `useTransition` so callers receive a pending signal during route commit. The adapter interface lives in `packages/views/navigation/types.ts`.
```tsx
// packages/views/navigation/context.tsx:19
const wrapped = useMemo<NavigationAdapter>(() => ({
...value,
push: (path) => startTransition(() => value.push(path)),
replace: (path) => startTransition(() => value.replace(path)),
}), [value]);
```
`AppLink` prevents default and delegates exclusively to the adapter:
```tsx
// packages/views/navigation/app-link.tsx:22
e.preventDefault();
onClick?.(e);
push(href);
```
Desktop and web each supply a concrete `NavigationAdapter` that satisfies the same surface. No file under `packages/views/` may import `next/navigation`, `react-router-dom`, or any tab-store symbol.
**Sources:** [packages/views/navigation/context.tsx:1-50](), [packages/views/navigation/app-link.tsx:1-60](), [packages/views/navigation/types.ts:1-45]()
## Desktop Route Categories
The navigation adapter (`apps/desktop/src/renderer/src/platform/navigation.tsx`) classifies every path before delegating:
1. **Session routes** — workspace-scoped paths of the form `/{slug}/{page}` (issues, projects, settings, etc.). These become real tabs under an independent `createMemoryRouter(appRoutes)` whose root element is `WorkspaceRouteLayout`. Each tab owns its own `DataRouter`; the tab bar only ever shows the active workspace's group.
2. **Transition flows** — pre-workspace or one-shot actions:
- `/workspaces/new`
- `/onboarding`
- `/invitations`
- `/invite/:id`
These are never routes. `tryRouteToOverlay` opens the corresponding `WindowOverlay` entry and, if necessary, forces the underlying tab router to `/`. The `WindowOverlay` component renders a full-window chrome above the entire tab system.
3. **Error / stale states** — a persisted tab whose slug no longer resolves (workspace deleted, access revoked, or migration artifact). `WorkspaceRouteLayout` detects the missing workspace in its query effect and calls `tabStore.validateWorkspaceSlugs()`. The entire group is removed and its routers disposed; no error page is ever mounted on desktop.
```tsx
// apps/desktop/src/renderer/src/platform/navigation.tsx:197
if (tryRouteToOverlay(path, active?.router)) return;
if (tryRouteToOtherWorkspace(path)) return;
if (tryRouteToPinnedNewTab(path)) return;
active?.router.navigate(path);
```
**Sources:** [apps/desktop/src/renderer/src/platform/navigation.tsx:52-140](), [apps/desktop/src/renderer/src/components/workspace-route-layout.tsx:70-110](), [apps/desktop/src/renderer/src/stores/window-overlay-store.ts:1-40]()
## Cross-Workspace Push Handling
When a `push` target slug differs from `activeWorkspaceSlug`, the adapter must not let the current memory router navigate. Instead it calls `tabStore.switchWorkspace(targetSlug, path)`:
```tsx
// apps/desktop/src/renderer/src/platform/navigation.tsx:102
function tryRouteToOtherWorkspace(path: string): boolean {
const targetSlug = extractWorkspaceSlug(path);
if (!targetSlug) return false;
const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
if (targetSlug === activeWorkspaceSlug) return false;
switchWorkspace(targetSlug, path);
return true;
}
```
`extractWorkspaceSlug` returns `null` for reserved slugs (`login`, `workspaces`, `invite`, etc.) so those paths are never misinterpreted as workspace navigation. The same check exists in both `DesktopNavigationProvider` (shell-level) and `TabNavigationProvider` (per-tab).
`switchWorkspace` either creates a fresh group with a default tab or activates an existing tab whose path matches `openPath`. Because tab groups live in a `Record<slug, WorkspaceTabGroup>`, a push can never deposit a tab into the wrong workspace's array.
**Sources:** [apps/desktop/src/renderer/src/platform/navigation.tsx:102-109](), [apps/desktop/src/renderer/src/stores/tab-store.ts:260-320](), [apps/desktop/src/renderer/src/stores/tab-store.ts:177-190]()
## Tab Isolation by Construction
`useTabStore` (Zustand + persist) maintains:
```ts
// apps/desktop/src/renderer/src/stores/tab-store.ts:39
interface TabStore {
activeWorkspaceSlug: string | null;
byWorkspace: Record<string, WorkspaceTabGroup>; // slug → { tabs, activeTabId }
...
}
```
Each `Tab` holds a stable `DataRouter` created by `createTabRouter(initialPath)`. `TabContent` renders every tab of the active group inside an `<Activity>` (visible vs. hidden) so hidden tabs preserve DOM and React state but only the active router receives events. When the active workspace changes, the previous workspace's tabs unmount entirely; cross-workspace state preservation is an explicit non-goal.
`validateWorkspaceSlugs` (called from the stale-slug effect) disposes routers for removed groups and picks the first remaining slug as the new active workspace. The TabBar and TabContent simply observe the store; they never contain cross-workspace logic.
**Sources:** [apps/desktop/src/renderer/src/stores/tab-store.ts:33-80](), [apps/desktop/src/renderer/src/stores/tab-store.ts:541-560](), [apps/desktop/src/renderer/src/components/tab-content.tsx:28-55]()
## Web Contrast
Web supplies its adapter directly from Next.js:
```tsx
// apps/web/platform/navigation.tsx:19
const adapter: NavigationAdapter = {
push: router.push,
replace: router.replace,
...
searchParams: new URLSearchParams(searchParams.toString()),
getShareableUrl: (p) => window.location.origin + p,
prefetch: router.prefetch,
};
```
All workspace-scoped routes live under `app/[workspaceSlug]/layout.tsx`. An unknown slug renders `<NoAccessPage/>` (the URL bar makes the error state meaningful). Pre-workspace flows (`/workspaces/new`, `/invite/:id`, `/onboarding`) are ordinary pages under the `(auth)` route group; they never need special interception.
Search params are first-class (`?issue=...`, `?filter=...`) and survive refreshes because they are part of the real URL. Desktop mirrors the active tab's location into the shell `DesktopNavigationProvider` so components outside any `TabNavigationProvider` (ChatWindow, global search) still see correct `searchParams`.
**Sources:** [apps/web/platform/navigation.tsx:19-45](), [apps/web/app/[workspaceSlug]/layout.tsx:70-110]()
## Invariants, Failure Modes, and Dependency Direction
**Invariants**
- Every live workspace group contains ≥1 tab.
- Every tab path is workspace-scoped and passes `sanitizeTabPath`.
- `setCurrentWorkspace(slug, wsId)` is only ever called from `WorkspaceRouteLayout` (desktop) or the equivalent Next.js layout (web) after the workspace has been successfully fetched.
- Shared code never holds a direct reference to a router or to `byWorkspace`.
**What breaks if the design changes**
- Removing the three early returns in the adapter push path would let a `/invite/abc` path become a real tab whose router would then throw when `useWorkspaceId()` is called.
- Flattening `byWorkspace` back into a single `tabs` array would re-introduce the original cross-workspace leakage bug that motivated the refactor.
- Allowing `packages/views/` to import `react-router-dom` would make the desktop memory-router model leak into the shared layer; a future web-only navigation primitive would then be impossible without duplication.
**Dependency direction**
`packages/views/` → `NavigationAdapter` (interface only)
`apps/desktop/.../platform/navigation.tsx` and `apps/web/platform/navigation.tsx` → concrete adapters + their stores/routers
`packages/core/platform/workspace-storage.ts` → `setCurrentWorkspace` singleton (read by API client and persisted stores)
**Sources:** [apps/desktop/src/renderer/src/platform/navigation.tsx:192-210](), [packages/core/platform/workspace-storage.ts:34-60](), [apps/desktop/src/renderer/src/components/workspace-route-layout.tsx:90-105]()
## Summary
The navigation system isolates three concerns—shared view contracts, platform route execution, and desktop tab lifecycle—by making the adapter the single point of classification. Desktop's `byWorkspace` map plus the overlay-vs-route distinction guarantees that `useNavigation().push("/beta/issues")` from any shared component either switches the visible workspace group or creates a tab inside the correct group, never both and never neither. Web reuses the same primitives over ordinary Next.js routing because its URL surface makes the extra machinery unnecessary. The architecture therefore satisfies the cross-platform rule while giving desktop the stronger isolation guarantees its window model demands.
**Sources:** [apps/desktop/src/renderer/src/platform/navigation.tsx:253-309](), [packages/views/navigation/context.tsx:30-50]()
---
## 07. Agent Execution & Runtime Model
> Assignee is polymorphic (assignee_type + assignee_id). An agent can be assigned exactly like a member; it claims work, streams progress, posts comments, and may create new issues. Two runtime surfaces exist: local daemon (CLI-managed, talks to desktop via daemon-ipc-bridge) and cloud runtimes. The daemon advertises available CLIs; the sweeper reaps dead runs. Every completed execution becomes a reusable skill stored under the workspace; future agents can invoke the same skill without re-learning. Health derivation and version detection live in packages/core/runtimes so both apps see identical status without duplication.
- Page Markdown: https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/07-agent-execution-runtime-model.md
- Generated: 2026-05-21T23:20:50.873Z
### Source Files
- `packages/core/runtimes/index.ts`
- `packages/core/runtimes/local-skills.ts`
- `packages/core/runtimes/cloud-runtime.ts`
- `packages/core/runtimes/derive-health.ts`
- `apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts`
- `server/migrations/001_init.up.sql`
- `server/cmd/server/runtime_sweeper.go`
<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [packages/core/runtimes/index.ts](packages/core/runtimes/index.ts)
- [packages/core/runtimes/local-skills.ts](packages/core/runtimes/local-skills.ts)
- [packages/core/runtimes/cloud-runtime.ts](packages/core/runtimes/cloud-runtime.ts)
- [packages/core/runtimes/derive-health.ts](packages/core/runtimes/derive-health.ts)
- [packages/core/runtimes/types.ts](packages/core/runtimes/types.ts)
- [packages/core/runtimes/use-runtime-health.ts](packages/core/runtimes/use-runtime-health.ts)
- [packages/core/runtimes/queries.ts](packages/core/runtimes/queries.ts)
- [packages/core/types/agent.ts](packages/core/types/agent.ts)
- [apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts](apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts)
- [server/migrations/001_init.up.sql](server/migrations/001_init.up.sql)
- [server/migrations/004_agent_runtime_loop.up.sql](server/migrations/004_agent_runtime_loop.up.sql)
- [server/migrations/008_structured_skills.up.sql](server/migrations/008_structured_skills.up.sql)
- [server/cmd/server/runtime_sweeper.go](server/cmd/server/runtime_sweeper.go)
- [server/internal/handler/daemon.go](server/internal/handler/daemon.go)
- [server/internal/daemon/daemon.go](server/internal/daemon/daemon.go)
- [server/internal/daemon/local_skills.go](server/internal/daemon/local_skills.go)
</details>
# Agent Execution & Runtime Model
Agents are first-class participants in Multica. An agent can be assigned to an issue exactly like a human member, claim work, stream progress, post comments, create child issues, and receive inbox notifications. Two execution surfaces exist: local daemons (managed by the `multica` CLI, discovered and controlled via the desktop app) and cloud runtimes (managed remote execution nodes). Every runtime heartbeat, task claim, and result flows through the server; completed executions can be captured as reusable workspace skills so future agents reuse learned behavior without re-discovery. Health derivation, version gating, and local-skill polling live in `packages/core/runtimes` so the web and desktop apps render identical status without duplication or drift.
## Polymorphic Assignees and Agent Participation
The data model treats members and agents uniformly for assignment and authorship:
- `issue.assignee_type` (`member` | `agent`) + `assignee_id`
- `issue.creator_type` + `creator_id`
- `comment.author_type` + `author_id`
- `inbox_item.recipient_type` + `recipient_id`
- `activity_log.actor_type`
```sql
-- server/migrations/001_init.up.sql:61
assignee_type TEXT CHECK (assignee_type IN ('member', 'agent')),
assignee_id UUID,
...
author_type TEXT NOT NULL CHECK (author_type IN ('member', 'agent')),
```
An agent row (`agent` table) carries `runtime_id`, `runtime_mode`, `skills[]` (via `agent_skill` join table), `status`, and `max_concurrent_tasks`. When an issue is assigned to an agent, the backend enqueues a row in `agent_task_queue` for that agent's runtime. The agent later claims, executes, and mutates the issue (status changes, comments, sub-issues) using the same primitives as a member.
Sources: [server/migrations/001_init.up.sql:36-73](server/migrations/001_init.up.sql), [server/migrations/008_structured_skills.up.sql:27-32](server/migrations/008_structured_skills.up.sql), [packages/core/types/agent.ts:116-150](packages/core/types/agent.ts)
## Runtime Model
A runtime is the execution substrate for one or more agents. The canonical row lives in `agent_runtime`:
```sql
-- server/migrations/004_agent_runtime_loop.up.sql:1-15
CREATE TABLE agent_runtime (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL,
daemon_id TEXT,
name TEXT NOT NULL,
runtime_mode TEXT NOT NULL CHECK (runtime_mode IN ('local', 'cloud')),
provider TEXT NOT NULL, -- "claude", "codex", "copilot", ...
status TEXT NOT NULL, -- "online" | "offline"
last_seen_at TIMESTAMPTZ,
metadata JSONB, -- versions, launched_by, etc.
UNIQUE (workspace_id, daemon_id, provider)
);
```
- `provider` identifies the concrete CLI (or cloud image) that will actually run the agent.
- `daemon_id` is the stable machine identifier for local daemons; cloud runtimes use a different key.
- An `agent` row always references exactly one `runtime_id` (NOT NULL after the 004 migration).
A single local daemon process can back multiple runtimes in the same workspace—one per discovered CLI provider.
Sources: [server/migrations/004_agent_runtime_loop.up.sql:1-93](server/migrations/004_agent_runtime_loop.up.sql), [server/internal/handler/daemon.go:293-331](server/internal/handler/daemon.go)
## Local Daemon Execution Surface
The `multica` CLI runs a daemon that:
1. Registers (or re-registers) its available runtimes on startup and after network flaps (`DaemonRegister`).
2. For each runtime, runs an independent poller (`runRuntimePoller`).
3. The poller acquires a concurrency slot *before* calling `ClaimTask` (prevents tasks from entering the 5-minute `dispatched` timeout window while the daemon is at capacity).
4. On claim, calls `StartTask`, spawns the provider CLI, streams `ReportProgress`, writes comments/results, and finally `CompleteTask` or `FailTask`.
5. Heartbeats every ~15 s; the server flushes to DB every 60 s.
Desktop augments the picture with an instantaneous path: `useDaemonIPCBridge` subscribes to Electron IPC status changes from the local daemon and patches the React Query runtime list cache directly, giving sub-second feedback while the server-side sweeper still sees the old `last_seen_at`.
```ts
// apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts:27
function mergeDaemonStatus(rt: AgentRuntime, status: DaemonStatusLike): AgentRuntime {
if (status.state === "running") {
return { ...rt, status: "online", last_seen_at: new Date().toISOString() };
}
...
}
```
Sources: [server/internal/daemon/daemon.go:1831-1954](server/internal/daemon/daemon.go), [apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts:55-76](apps/desktop/src/renderer/src/platform/daemon-ipc-bridge.ts), [server/internal/handler/daemon.go:238-411](server/internal/handler/daemon.go)
## Cloud Runtimes
Cloud runtimes (`runtime_mode = 'cloud'`) are represented by the same `agent_runtime` rows but are created and lifecycle-managed through the `cloudRuntime` surface (`packages/core/runtimes/cloud-runtime.ts`). They appear as nodes with `instance_type`, `region`, `status` (launching/pending/running/...), and are billed/terminated via the cloud provider. Task dispatch and claim semantics are identical; only the execution environment differs.
Sources: [packages/core/runtimes/cloud-runtime.ts:4-81](packages/core/runtimes/cloud-runtime.ts)
## Task Lifecycle and the Runtime Sweeper
```
queued → dispatched (ClaimTask) → running (StartTask) → completed | failed | cancelled
```
The sweeper (`runRuntimeSweeper`, 30 s interval) enforces liveness:
- Marks `online` runtimes `offline` when `last_seen_at` > 150 s stale (Redis liveness store is the fast path; DB is the fallback).
- Fails any `dispatched`/`running` tasks whose runtime just went offline.
- Expires `queued` tasks older than 2 h (backlog safety valve).
- GCs `offline` runtimes with no active agents after 7 days (`offlineRuntimeTTLSeconds`).
```go
// server/cmd/server/runtime_sweeper.go:72
func runRuntimeSweeper(...) {
for {
...
sweepStaleRuntimes(...) // marks offline + fails orphans
sweepStaleTasks(...)
sweepExpiredQueuedTasks(...)
gcRuntimes(...) // 7-day TTL
}
}
```
Sources: [server/cmd/server/runtime_sweeper.go:72-87,89-169,208-235,259-280](server/cmd/server/runtime_sweeper.go)
## Health Derivation (Shared, Time-Bucketed)
Raw server state is binary (`status` + `last_seen_at`). The UI needs four buckets so users can distinguish "just died" from "long dead and about to disappear."
```ts
// packages/core/runtimes/derive-health.ts:15
export function deriveRuntimeHealth(runtime: AgentRuntime, now: number): RuntimeHealth {
if (runtime.status === "online") return "online";
const offlineFor = now - (lastSeen ?? 0);
if (offlineFor < 5 * 60_000) return "recently_lost";
if (offlineFor > 6 * 24 * 3600_000) return "about_to_gc";
return "offline";
}
```
`useRuntimeHealth` (core) re-renders on a 30 s tick so the buckets advance even without new server data. Desktop's IPC bridge overrides the local daemon's row in the cache for near-instant transition out of "recently_lost".
Sources: [packages/core/runtimes/derive-health.ts:15-27](packages/core/runtimes/derive-health.ts), [packages/core/runtimes/types.ts:6-11](packages/core/runtimes/types.ts), [packages/core/runtimes/use-runtime-health.ts:28-46](packages/core/runtimes/use-runtime-health.ts)
## Skills: Persisted, Reusable Execution Knowledge
After the 008 migration, skills are first-class workspace entities:
- `skill` + `skill_file` (the `SKILL.md` + supporting files)
- `agent_skill` many-to-many
Local daemons expose a discovery surface (`resolveRuntimeLocalSkills`) that walks provider-specific directories (`~/.claude/skills`, `~/.codex/skills`, `~/.cursor/skills`, etc.), parses frontmatter, and returns summaries. The UI can then import a selected skill; the daemon bundles the tree and the server materializes it as a workspace `skill` record. Agents attached to that skill (or given it later) receive the content at task execution time via `LoadAgentSkills`.
Thus a successful agent run that produced useful artifacts or refined instructions can be captured once and reused by any agent in the workspace without the next run "re-learning" the same pattern.
Sources: [server/migrations/008_structured_skills.up.sql:4-42](server/migrations/008_structured_skills.up.sql), [packages/core/runtimes/local-skills.ts:29-78](packages/core/runtimes/local-skills.ts), [server/internal/daemon/local_skills.go:54-84,242-272](server/internal/daemon/local_skills.go)
## Why the Core Package Boundary Matters
`packages/core/runtimes/*` (derive-health, cli-version checks, local-skill polling, cloud node hooks, React Query keys) is deliberately free of `react-dom`, `localStorage`, and framework routing. Both the Next.js web app and the Electron renderer import the identical functions. Desktop-only fast-path logic lives in `apps/desktop/.../platform/daemon-ipc-bridge.ts` and only mutates the shared Query cache. Any change to health bucketing or version parsing is automatically consistent across surfaces.
If health logic had lived in each app, the 5-minute / 6-day thresholds would have diverged the moment one side was edited.
Sources: [packages/core/runtimes/index.ts:1-12](packages/core/runtimes/index.ts), [packages/core/runtimes/queries.ts:51-56](packages/core/runtimes/queries.ts)
## Summary
The execution model is deliberately simple at the boundary: polymorphic assignees + a task queue + per-runtime claim loops + a single source of truth for runtime health and skills in the core package. The daemon and cloud surfaces differ only in where the provider process actually runs; everything else (registration, heartbeats, sweeping, skill import, UI derivation) is shared. This keeps the mental model small and prevents the classic "web works, desktop is stale" or "local daemon shows green while server already GC'd it" failure modes.
Sources: [server/cmd/server/runtime_sweeper.go:89-169](server/cmd/server/runtime_sweeper.go)
---
## 08. Core Invariants & Safe Evolution Rules
> The system stays coherent only while these hold: (1) server facts never live in Zustand, (2) WS events only invalidate, (3) every workspace-scoped query keys on wsId, (4) mutations are optimistic then invalidate, (5) core never touches react-dom or framework routers, (6) views never import next/* or react-router, (7) UUIDs from the wire are validated before any write query, (8) desktop destructive workspace ops call setCurrentWorkspace(null) before the mutation. Violating any produces drift, white-screens on older desktop builds, or cross-workspace data leaks. To change safely: add the Zod response schema in the same PR, write a test that feeds it malformed data, run make check, and only then consider the contract stable. These rules are the price of a desktop app that is always older than the server it talks to.
- Page Markdown: https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/pages/08-core-invariants-safe-evolution-rules.md
- Generated: 2026-05-21T23:20:26.071Z
### Source Files
- `CLAUDE.md`
- `AGENTS.md`
- `packages/core/platform/core-provider.tsx`
- `server/internal/handler/handler.go`
- `server/internal/util/pgx.go`
- `packages/core/runtimes/derive-health.ts`
- `Makefile`
<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [AGENTS.md](AGENTS.md)
- [CLAUDE.md](CLAUDE.md)
- [Makefile](Makefile)
- [packages/core/api/schema.ts](packages/core/api/schema.ts)
- [packages/core/api/schemas.ts](packages/core/api/schemas.ts)
- [packages/core/platform/core-provider.tsx](packages/core/platform/core-provider.tsx)
- [packages/core/platform/workspace-storage.ts](packages/core/platform/workspace-storage.ts)
- [packages/core/realtime/use-realtime-sync.ts](packages/core/realtime/use-realtime-sync.ts)
- [packages/core/runtimes/derive-health.ts](packages/core/runtimes/derive-health.ts)
- [packages/core/workspace/mutations.ts](packages/core/workspace/mutations.ts)
- [packages/core/workspace/queries.ts](packages/core/workspace/queries.ts)
- [packages/views/settings/components/workspace-tab.tsx](packages/views/settings/components/workspace-tab.tsx)
- [server/internal/handler/handler.go](server/internal/handler/handler.go)
- [server/internal/util/pgx.go](server/internal/util/pgx.go)
</details>
# Core Invariants & Safe Evolution Rules
These eight rules keep the Multica system coherent across a Go backend, two frontend apps (Next.js web and Electron desktop), and three shared packages. The desktop app on a user's machine is always older than the server it talks to; shared code must therefore survive response-shape drift, workspace switches, and concurrent realtime events without drift, leaks, or hard reloads. The rules are not suggestions. Each was written after a concrete production incident (e.g., #1661, #2143, #2147, #2192).
The invariants fall into three groups: strict separation of server vs. client state, strict package boundaries for cross-platform reuse, and defensive request/response handling plus explicit sequencing for destructive operations.
## State Ownership (React Query vs. Zustand)
**TanStack Query owns every fact that came from the server.** Issues, members, agents, runtimes, inbox, workspace lists, usage, timelines — anything returned by an API call lives only in the Query cache. **Zustand owns only client-side ephemeral state**: current workspace singleton, view filters, drafts, modal open/closed flags, navigation history, and a few UI selections. No server-derived value is ever copied into a Zustand store.
**WebSocket events only invalidate.** The single realtime sync module (`use-realtime-sync.ts`) receives every WS message and calls `qc.invalidateQueries({ queryKey: ... })`. It never calls `setQueryData` on server data (except a few chat write-through cases that still treat the Query cache as source of truth) and never touches any Zustand store. When the connection drops and reconnects, it invalidates the current workspace's scoped queries so the next refetch sees reality.
**Every workspace-scoped query key contains the wsId.** `workspaceKeys.agents(wsId)`, `issueKeys.all(wsId)`, `runtimeKeys.all(wsId)`, etc. all receive the workspace UUID as the first or second segment. When the user switches workspaces the URL changes, `setCurrentWorkspace` updates the singleton, the QueryClient key changes, and the correct cached data (or a fresh fetch) appears automatically. No manual invalidation or store reset is required.
**Mutations are optimistic then invalidate on settle.** The caller updates the cache locally in `onMutate`, the request is fired, and `onSettled` (or `onSuccess`/`onError`) calls `invalidateQueries`. The UI never waits for the round-trip.
Sources: [CLAUDE.md:52-67](CLAUDE.md), [packages/core/workspace/queries.ts:5-66](packages/core/workspace/queries.ts), [packages/core/realtime/use-realtime-sync.ts:149-166](packages/core/realtime/use-realtime-sync.ts), [packages/core/projects/resource-queries.ts:54-69](packages/core/projects/resource-queries.ts)
## Package Boundaries (Cross-Platform Without Duplication)
`packages/core/` is pure headless logic. It contains zero `react-dom`, zero `localStorage` (only the injected `StorageAdapter`), zero `process.env`, and zero direct imports of `next/*` or `react-router-dom`. All Zustand stores that both apps need live here. The only way an app-specific value (a slug from the URL) reaches core is through a React Context (`WorkspaceSlugProvider`, `WorkspaceIdProvider`) whose concrete provider is supplied by the app's platform layer.
`packages/views/` contains only UI that both apps can render. It imports zero `next/*` and zero `react-router-dom`. All navigation is performed through the `NavigationAdapter` interface (`useNavigation().push`, `<AppLink>`). Any file that needs framework routing lives in `apps/web/app/...` or `apps/desktop/.../platform/...`.
`packages/ui/` contains zero `@multica/core` imports; it is pure presentational components and tokens.
These boundaries guarantee that a change in one app cannot break the other and that the same business page component can be dropped into both shells.
Sources: [AGENTS.md:30-35](AGENTS.md), [CLAUDE.md:182-190](CLAUDE.md), [packages/core/platform/core-provider.tsx:1-124](packages/core/platform/core-provider.tsx), [packages/core/paths/hooks.tsx:16-18](packages/core/paths/hooks.tsx)
## Request Safety & Desktop Sequencing (Older Clients, Multi-Workspace Isolation)
**UUIDs arriving from the wire are validated before any write query.** `parseUUIDOrBadRequest` (and the loader helpers `loadIssueForUser`, `loadAgentForUser`, etc.) are the only approved entry points for user-supplied identifiers. They call `util.ParseUUID` (which returns an error instead of a zero UUID) and write a 400 on failure. Trusted round-trips (sqlc results, test fixtures) may use the panicking `MustParseUUID` wrapper, but raw `chi.URLParam` or JSON body strings never reach a `DELETE` or `UPDATE` without the guard. The rule exists because the old silent-zero behavior produced `DELETE` returning 204 while zero rows were affected (#1661).
**Desktop destructive workspace operations clear the workspace singleton before the mutation.** Leave and delete flows in the settings tab first compute the post-action destination from the still-valid cached workspace list, call `setCurrentWorkspace(null, null)`, navigate, and only then `await mutateAsync`. The explicit null prevents three races: the realtime `workspace:deleted` handler seeing the old workspace and firing a conflicting navigation, the API client still emitting an `X-Workspace-Slug` header for the deleted workspace, and the sidebar chrome reading a stale `useWorkspaceId()` while the list no longer contains the ID. The same sequence is required for any operation that removes the current workspace from the user's membership.
Sources: [server/internal/util/pgx.go:11-28](server/internal/util/pgx.go), [server/internal/handler/handler.go:189-203](server/internal/handler/handler.go), [packages/views/settings/components/workspace-tab.tsx:75-97](packages/views/settings/components/workspace-tab.tsx), [packages/core/platform/workspace-storage.ts:34-64](packages/core/platform/workspace-storage.ts)
## Safe Evolution of API Contracts
Because a desktop binary installed months earlier can still be talking to a server that has advanced several minor versions, every response that reaches UI code is treated as untrusted JSON.
- Add or change an endpoint response → add (or update) the corresponding Zod schema in the same PR.
- The schema must be lenient: strings instead of literal enums, `.loose()` on objects, arrays defaulting to `[]`, optional fields with explicit fallbacks.
- Write at least one test that feeds a deliberately malformed body (missing field, wrong type, extra unknown field, `null` array) through `parseWithFallback` and asserts the fallback value is returned.
- Run `make check` (typecheck + Vitest + Go tests + E2E).
Only after the schema + negative test land is the contract considered stable. `parseWithFallback` plus the explicit `EMPTY_*` fallbacks are the only mechanism that prevents white-screens on older desktop builds.
Sources: [CLAUDE.md:156-169](CLAUDE.md), [packages/core/api/schema.ts:38-47](packages/core/api/schema.ts), [packages/core/api/schemas.ts:18-43](packages/core/api/schemas.ts), [packages/core/api/client.ts:372-375](packages/core/api/client.ts)
## What Breaks When a Rule Is Violated
| Invariant violated | Observable failure |
|--------------------|--------------------|
| Server data duplicated into Zustand | Two sources of truth drift; WS updates one, UI reads the other |
| WS handler writes stores directly | Stale data after reconnect; lost updates when component unmounts |
| Query omits wsId from key | Workspace A sees workspace B's issues after switch |
| Mutation lacks optimistic + invalidate | User waits for round-trip; perceived lag |
| Core imports `react-dom` or a router | Desktop build fails or web SSR breaks |
| Views imports `next/*` or `react-router-dom` | The other app cannot render the shared page |
| Raw UUID reaches write query | Silent data loss (DELETE 204, 0 rows) |
| Desktop delete/leave omits `setCurrentWorkspace(null)` | Concurrent refetches cancel, hard reload, or cross-workspace header leak |
These are not theoretical. Each cell corresponds to a past incident that produced exactly that symptom until the rule was written down and enforced.
## Summary
The eight invariants are the minimum set of constraints that let a single Go server safely serve both a browser SPA and a long-lived Electron desktop application while sharing the majority of business logic and UI. They are maintained by a combination of architectural boundaries (package rules), runtime discipline (invalidation-only WS, wsId keys, explicit nulling), and defensive parsing (Zod + `parseWithFallback`). Any change that would weaken one of them must be accompanied by a schema, a malformed-data test, and a successful `make check` before it is considered complete.
Sources: [CLAUDE.md:52-67,156-180,241-248](CLAUDE.md)
---