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

- 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

- `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)
