# 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.

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

## Source Files

- `packages/core/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]()
