---
title: "Zustand store rearchitecture - active context, realtime en resync"
status: ready-to-execute
audience: [maintainer, contributor, ai-agent]
language: nl
last_updated: 2026-05-09
revision: 3
---

# Zustand store rearchitecture

Doel: de client-state van Scrum4Me voorspelbaar houden terwijl de app groeit.
De database blijft de bron van waarheid. Zustand wordt de live client-projectie
voor de actieve workflow: snel genoeg voor optimistic UI, robuust genoeg tegen
gemiste SSE-events, hidden tabs en onbekende notify-vormen.

## Kernkeuze

Geen app-brede megastore en ook geen pure splits per pagina. Stores per bounded
context:

| Store | Scope | Verantwoordelijkheid |
|---|---|---|
| `product-workspace-store` | actief product | active context, product backlog data, selectie, DnD-order, SSE, resync |
| `sprint-workspace-store` | actieve sprint | sprint stories, sprint tasks, assignment, sprint-DnD, SSE, resync |
| `solo-store` | actieve user + product | uitvoerbare taken, worker/job status, solo-kanban realtime |
| `notifications-store` | user | vragen, alerts, notification badge |
| `idea-store` | idee/product | grill/make-plan jobstate, open vragen, idea-status |
| `jobs-store` | jobs pagina | actieve/afgeronde jobs en jobs-page selectie |
| kleine UI stores | app/client | debug mode, lichte UI-voorkeuren |

De huidige `backlog-store`, `planner-store`, `selection-store` en
`product-store` worden samengevoegd tot `product-workspace-store`. Ze
beschrijven dezelfde workflow en verdelen nu PBI/story/task waarheid,
order-state en selectie over meerdere stores.

## Source of truth

| Data | Waarheid | Zustand rol | Persistentie |
|---|---|---|---|
| `activeProductId` | `users.active_product_id` in DB | client mirror voor navigatie en actieve stream | DB |
| `activePbiId` | runtime selectie of URL | active context | optioneel localStorage restore hint |
| `activeStoryId` | runtime selectie of URL | active context | optioneel localStorage restore hint |
| `activeTaskId` | runtime selectie of URL dialog param | active context + task detail | optioneel localStorage restore hint |
| PBI/story/task data | DB | genormaliseerde live client-projectie | geen localStorage |
| DnD-order | DB `sort_order`, tijdelijk optimistic in store | relatie-arrays met rollback | DB na server action |
| filters/sort UI | client preference | component/store UI state | localStorage mag |

LocalStorage is dus geen waarheid voor actieve entiteiten. Het mag alleen helpen
om een vorige selectie te herstellen nadat de server de actieve product-context
heeft bepaald — én alleen als de hint-id na laden nog bestaat in de store.

## Product workspace store

Vorm:

```ts
type ProductWorkspaceStore = {
  context: {
    activeProduct: { id: string; name: string } | null
    activePbiId: string | null
    activeStoryId: string | null
    activeTaskId: string | null
  }

  entities: {
    pbisById: Record<string, BacklogPbi>
    storiesById: Record<string, BacklogStory>
    tasksById: Record<string, BacklogTask | TaskDetail>
  }

  relations: {
    pbiIds: string[]
    storyIdsByPbi: Record<string, string[]>
    taskIdsByStory: Record<string, string[]>
  }

  loading: {
    loadedProductId: string | null
    loadingProductId: string | null
    loadedPbiIds: Record<string, true>
    loadedStoryIds: Record<string, true>
    loadedTaskIds: Record<string, true>
    activeRequestId: string | null
  }

  sync: {
    realtimeStatus: 'connecting' | 'open' | 'disconnected'
    lastEventAt: number | null
    lastResyncAt: number | null
    resyncReason: ResyncReason | null
  }

  hydrateSnapshot(snapshot: ProductBacklogSnapshot): void
  setActiveProduct(product: { id: string; name: string } | null): void
  setActivePbi(pbiId: string | null): void
  setActiveStory(storyId: string | null): void
  setActiveTask(taskId: string | null): void

  ensureProductLoaded(productId: string, requestId?: string): Promise<void>
  ensurePbiLoaded(pbiId: string, requestId?: string): Promise<void>
  ensureStoryLoaded(storyId: string, requestId?: string): Promise<void>
  ensureTaskLoaded(taskId: string, requestId?: string): Promise<void>

  applyRealtimeEvent(event: ProductRealtimeEvent): void
  resyncActiveScopes(reason: ResyncReason): Promise<void>
  resyncLoadedScopes(reason: ResyncReason): Promise<void>

  applyOptimisticMutation(mutation: OptimisticMutation): string
  rollbackMutation(mutationId: string): void
  settleMutation(mutationId: string): void
}
```

State blijft vlak en genormaliseerd. Componenten lezen via selectors:

```ts
selectVisiblePbis(state)
selectStoriesForActivePbi(state)
selectTasksForActiveStory(state)
selectActivePbi(state)
selectActiveStory(state)
selectActiveTask(state)
```

Een task zit in `tasksById` als `BacklogTask` (lite) zolang alleen de lijst
geladen is, en wordt verrijkt naar `TaskDetail` zodra `ensureTaskLoaded` is
gedraaid. Componenten gebruiken een `isDetail()`-typeguard voor de extra
velden.

## Active context flow

### Product wisselen

```txt
setActiveProduct(product)
-> nieuw requestId, zet activeRequestId
-> zet activeProduct, reset activePbiId/activeStoryId/activeTaskId
-> reset entities + relations als product wisselt
-> SSE-stream wisselt mee met product.id
-> ;(async) await ensureProductLoaded(product.id, requestId)
-> nadat ensure resolved + activeRequestId nog == requestId:
   probeer restore hint (activePbiId) — alleen als hint-id in entities zit
```

`activeProduct` komt server-side uit de layout via `users.active_product_id`.
De client-store spiegelt dit zodat client componenten niet overal props hoeven
te ontvangen.

### PBI selecteren

```txt
setActivePbi(pbiId)
-> nieuw requestId
-> zet activePbiId, reset activeStoryId en activeTaskId
-> schrijf lastActivePbiIdByProduct[productId] als restore hint
-> ;(async) await ensurePbiLoaded(pbiId, requestId)
-> nadat ensure resolved + activeRequestId nog == requestId:
   probeer restore hint (activeStoryId)
```

### Story selecteren

```txt
setActiveStory(storyId)
-> nieuw requestId
-> zet activeStoryId, reset activeTaskId
-> ensureStoryLoaded(storyId, requestId)
-> schrijf lastActiveStoryIdByProduct[productId] als restore hint
```

### Task selecteren

```txt
setActiveTask(taskId)
-> zet activeTaskId
-> ensureTaskLoaded(taskId)
-> schrijf lastActiveTaskIdByProduct[productId] als restore hint
```

### Race-safe loaders

Setters mogen loaders starten, maar loaders moeten race-safe zijn.

```ts
setActivePbi(pbiId) {
  const requestId = crypto.randomUUID()

  set((s) => {
    s.context.activePbiId = pbiId
    s.context.activeStoryId = null
    s.context.activeTaskId = null
    s.loading.activeRequestId = requestId
  })

  void get().ensurePbiLoaded(pbiId, requestId)
}
```

Bij terugkomst:

```ts
if (get().loading.activeRequestId !== requestId) return
```

Een trage fetch van een oude selectie mag nooit de nieuwste selectie of data
overschrijven.

> **Niet `state.ensureXxx(...)` aanroepen vanuit een gecaptured snapshot.**
> Method-references zijn niet noodzakelijk identiek over verschillende
> immer-state-versies. Roep acties intern aan via `get().ensureXxx(...)`
> direct vóór gebruik. Zie §Implementation-gotchas G4.

## Hydration-strategie

Er zijn twee patronen. Kies bewust per pagina.

### Patroon A — Server snapshot (productpagina, sprintboard)

Voor pagina's die een specifieke product-route hebben en SSR/RSC kunnen
benutten:

```txt
1. Server layout leest `users.active_product_id`.
2. Server-page fetcht initial backlog snapshot voor dat product.
3. Client krijgt snapshot via prop / `BacklogHydrationWrapper` → `hydrateSnapshot()`.
4. Vervolgens:
   - leest store optionele restore hints (activePbiId/Story/Task).
   - Herstelt alleen als id nog bestaat in entities en toegankelijk is.
   - Anders selectie leeg laten.
5. SSE-hook mount op activeProductId.
```

### Patroon B — Cascading client-load (productpicker zonder server-context)

Voor pagina's zonder server-determined product (b.v. een dashboard met
product-pulldown). De `hydrateSnapshot` blijft beschikbaar als API maar wordt
niet gebruikt; loaders worden door de UI getriggerd via `setActiveProduct`,
`setActivePbi`, etc.

```txt
1. UI biedt productpicker; geen server-side activeProduct.
2. Op mount: lees `lastActiveProductId` uit localStorage (als hint).
3. setActiveProduct(restoredProduct) → trigger ensureProductLoaded.
4. Na elke ensure*Loaded: pas vervolg-restore-hint toe (zie restore-flow).
5. SSE-hook mount op activeProductId.
```

### Restore-hint flow

```txt
setActiveProduct(p):
  ;(async () => {
    await ensureProductLoaded(p.id, requestId)
    if (loading.activeRequestId !== requestId) return
    const hint = hints.perProduct[p.id]?.lastActivePbiId
    if (hint && entities.pbisById[hint]) setActivePbi(hint)
  })()

setActivePbi(pbiId):
  ;(async () => {
    await ensurePbiLoaded(pbiId, requestId)
    if (loading.activeRequestId !== requestId) return
    const hint = hints.perProduct[productId]?.lastActiveStoryId
    if (hint && entities.storiesById[hint]) setActiveStory(hint)
  })()
```

> **Geen setTimeout(0) of microtask-trick.** De fetch is dan nog niet klaar,
> dus de validatie `entities.byId[hint]` faalt altijd. Chain dus altijd
> `await ensureXxxLoaded` en valideer in dezelfde requestId-cycle.

Als een route een expliciete task in de URL heeft, wint de URL boven de
restore hint. Voorbeeld: `?editTask=<id>` of een toekomstige deep link.

## SSE integratie

De SSE-hook beheert alleen transport:

```txt
useProductWorkspaceRealtime(activeProductId)
-> opent /api/realtime/backlog?product_id=...
-> parsed events
-> dispatcht naar store.applyRealtimeEvent(event)
-> beheert reconnect/backoff/status (via store.setRealtimeStatus)
-> op 'ready' na (re)connect: void store.resyncActiveScopes('reconnect')
```

De store beheert de betekenis:

```txt
PBI insert/update
-> upsert pbisById
-> voeg id toe aan pbiIds indien nodig
-> sorteer pbiIds op priority/sort_order

PBI delete
-> verwijder pbi
-> verwijder child stories en tasks
-> clear actieve selectie als die onder deze PBI viel

Story insert/update
-> upsert storiesById
-> verplaats id tussen storyIdsByPbi indien pbi_id wijzigt
-> sorteer alleen de betrokken parent-lijsten

Story delete
-> verwijder story
-> verwijder child tasks
-> clear activeStoryId/activeTaskId indien nodig

Task insert/update
-> upsert tasksById
-> verplaats id tussen taskIdsByStory indien story_id wijzigt
-> sorteer alleen de betrokken task-lijst

Task delete
-> verwijder task
-> clear activeTaskId indien nodig
```

SSE-events zijn idempotent. Een event dat al optimistisch is toegepast, mag
geen dubbele insert of verkeerde rollback veroorzaken.

## Reconciliation en resync

SSE is snel, maar niet voldoende als enige correctheidsmechanisme:

- browsers kunnen hidden tabs throttlen of freezen;
- Postgres NOTIFY heeft geen replay;
- de tab kan offline zijn;
- een event-router kan een relevant semantisch event niet herkennen;
- sommige wijzigingen vereisen refetch in plaats van een kleine patch.

Daarom krijgt de store een expliciete resync-laag.

```ts
type ResyncReason =
  | 'visible'
  | 'reconnect'
  | 'manual'
  | 'unknown-event'
  | 'stale-scope'
  | 'mutation-settled'
```

### Hidden tab beleid

Sluit de SSE-stream niet actief zodra de tab hidden wordt. Laat `EventSource`
open zolang browser en netwerk dit toelaten.

Bij overgang hidden -> visible:

```txt
resyncActiveScopes('visible')
```

Bij reconnect of nieuw `ready` event na disconnect:

```txt
resyncActiveScopes('reconnect')
```

> Het bestaande `use-backlog-realtime.ts` sluit de EventSource op `hidden`.
> Vervang dat gedrag in dezelfde PR als waarin `resyncActiveScopes('visible')`
> wordt toegevoegd; los gezien zou je oude gedrag kwijtraken zonder vangnet.

### Active scopes

Minimale resync:

```txt
activeProductId -> refetch product/PBI snapshot
activePbiId     -> refetch stories voor PBI
activeStoryId   -> refetch tasks voor story
activeTaskId    -> refetch task detail
```

Implementeer `resyncActiveScopes` zonder gecaptured snapshot:

```ts
async resyncActiveScopes(reason) {
  const ctx = get().context
  const tasks: Promise<void>[] = []
  if (ctx.activeProduct?.id) tasks.push(get().ensureProductLoaded(ctx.activeProduct.id))
  if (ctx.activePbiId)       tasks.push(get().ensurePbiLoaded(ctx.activePbiId))
  if (ctx.activeStoryId)     tasks.push(get().ensureStoryLoaded(ctx.activeStoryId))
  if (ctx.activeTaskId)      tasks.push(get().ensureTaskLoaded(ctx.activeTaskId))
  set((s) => { s.sync.lastResyncAt = Date.now(); s.sync.resyncReason = reason })
  await Promise.allSettled(tasks)
}
```

Als de UX merkt dat eerder bezochte panels stale blijven, breid dit uit naar
`resyncLoadedScopes`, dat alle scopes in `loadedPbiIds`, `loadedStoryIds` en
`loadedTaskIds` parallel herlaadt.

### Unknown relevant events

Een SSE-route mag onbekende product-events niet stil negeren — maar ook niet
elk geluid blind als refetch-trigger interpreteren.

```txt
known pbi/story/task event
-> applyRealtimeEvent(event)

known semantic event, zoals story_log of claude_job_status
-> patch specifieke slice of markeer scope stale

unknown event met product_id == activeProductId EN entity-shape
-> resyncActiveScopes('unknown-event')

worker_*, claude_job_*, heartbeat, question-events
-> negeer voor de product-workspace, behoort op andere bounded contexts
   (solo-store, notifications-store, jobs-store)
```

Concrete filter:

```ts
function isUnknownEntityEvent(p: Record<string, unknown>): boolean {
  if (typeof p.entity !== 'string') return false
  if (['pbi', 'story', 'task'].includes(p.entity)) return false
  if ('type' in p) return false // job/worker hebben `type`
  return true
}
```

## Fetch en cache regels

Read-routes die store-data voeden:

```ts
export const dynamic = 'force-dynamic'
```

Client fetches vanuit `ensure...Loaded` en `resync...`:

```ts
fetch(url, { cache: 'no-store' })
```

SSE-routes blijven ook `force-dynamic`. SSE-routes zelf veranderen niet door
deze rearchitecture: auth (`getSession()`) en `getAccessibleProduct()` blijven
leidend. De rearchitecture raakt alleen wat de client met de events doet.

Waar nuttig stuurt de SSE-route na `LISTEN` een initial state event. Dat
voorkomt de race waarbij de status wijzigt tussen de eerste DB-read en het
moment dat LISTEN actief is.

## Optimistic updates

Voor DnD en status toggles:

```txt
1. Maak mutationId.
2. Bewaar rollback snapshot van alleen de betrokken relatie/entity.
3. Patch store direct.
4. Start server action.
5. Success: settle mutation.
6. Error: rollback mutation.
7. SSE echo: idempotent toepassen of markeren als bevestigd.
```

Voor create/update/delete dialogs is optimisme optioneel. Standaard mag:

```txt
server action -> resultaat in store verwerken -> SSE echo idempotent negeren
```

## Sprint workspace

Na stabilisatie van product-workspace volgt dezelfde vorm voor sprint:

```txt
sprint-workspace-store
  activeSprintId
  selectedStoryId
  selectedTaskId
  storiesById
  tasksById
  pbisById
  storyIdsByPbi
  sprintStoryIds
  taskIdsByStory
  loaded scopes
  applyRealtimeEvent()
  resyncActiveScopes()
```

Dit vervangt op termijn de combinatie van lokale state in de sprint board en
de huidige `sprint-store` order maps. Pak het pas op nadat product-workspace
in productie staat en de eerste paar weken stabiel draait.

## Implementation-gotchas

Deze pitfalls zijn subtiel genoeg om opnieuw te maken. Documenteer ze in code
via comments boven de fix.

### G1. `s.byId[x] ?? []` triggert React-loop

```ts
// FOUT: nieuwe array per render → "Maximum update depth exceeded"
const stories = useStore((s) => s.storiesByPbi[pbiId] ?? [])

// GOED: stable empty const op module-level
const EMPTY: BacklogStory[] = []
const stories = useStore((s) => s.storiesByPbi[pbiId] ?? EMPTY)
```

### G2. List-selectors materialiseren → `useShallow` verplicht

`selectVisiblePbis(state)` en vrienden bouwen `ids.map(id => byId[id])` per
call. Zonder `useShallow` re-rendert het component op elke onafhankelijke
store-mutatie.

```ts
import { useShallow } from 'zustand/react/shallow'
const list = useStore(useShallow(selectVisiblePbis))
```

Single-value selectors (b.v. `selectActivePbi`) hebben dit niet nodig — die
retourneren een stable entity-reference.

### G3. immer + `setState((s) => ({...}))` REPLACES de state

Met `zustand/middleware/immer` interpreteert `produce` een return-waarde als
de nieuwe state. Dat lijkt op de pre-immer Zustand-API maar wist alle andere
slices én alle action-properties.

```ts
// FOUT (in immer-middleware): vervangt hele state met { context: {...} }
useStore.setState((s) => ({ context: { ...s.context, activePbiId: 'x' } }))

// GOED: mutation-style (immer recipe muteert draft)
useStore.setState((s) => { s.context.activePbiId = 'x' })
```

### G4. Method-refs zijn niet stabiel over state-versies

```ts
// FOUT: state-snapshot kan andere method-ref hebben dan de huidige
async resyncActiveScopes(reason) {
  const state = get()
  // ...
  state.ensureProductLoaded(...) // niet betrouwbaar
}

// GOED: per call fresh ophalen via get()
async resyncActiveScopes(reason) {
  const ctx = get().context
  // ...
  get().ensureProductLoaded(...)
}
```

### G5. Tests die acties mocken via setState lekken naar volgende tests

`useStore.setState({ resyncActiveScopes: vi.fn() })` blijft staan na de test.
`beforeEach` reset alleen data-velden. Snapshot originele acties op
module-load + restore in `beforeEach`:

```ts
const originalActions = (() => {
  const s = useStore.getState()
  return { resyncActiveScopes: s.resyncActiveScopes, /* ... */ }
})()

function resetStore() {
  useStore.setState({ ...initialData, ...originalActions })
}
```

### G6. localStorage in vitest 4 + jsdom 29

In deze combinatie is `localStorage.clear/getItem/setItem` niet aanwezig op
het globale localStorage-object. Bind in `tests/setup.ts` een eigen
MemoryStorage:

```ts
class MemoryStorage implements Storage { /* ... */ }
const memory = new MemoryStorage()
Object.defineProperty(globalThis, 'localStorage', { value: memory, configurable: true })
Object.defineProperty(window, 'localStorage', { value: memory, configurable: true })
```

### G7. `fetch` in node-test omgeving accepteert geen relative URLs

`/api/products/...` faalt met `Invalid URL`. Mock fetch in elke test die
indirect een `ensure*Loaded` aanroept, of stub de implementatie.

### G8. `Response`-body wordt één keer geconsumeerd

`vi.spyOn(fetch).mockResolvedValue(response)` levert dezelfde Response aan
elke fetch — eerste `.json()` werkt, daarna error. Gebruik
`mockImplementation()` voor een fresh body per call:

```ts
vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
  Promise.resolve(new Response(JSON.stringify([]), { status: 200 })),
)
```

## Testing setup (Vitest)

Minimale config:

```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import path from 'node:path'
export default defineConfig({
  test: {
    environment: 'jsdom',
    include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'],
    setupFiles: ['tests/setup.ts'],
  },
  resolve: { alias: { '@': path.resolve(__dirname, '.') } },
})
```

`tests/setup.ts` bevat MemoryStorage-binding (zie G6) en `vi.restoreAllMocks()`
in `beforeEach`.

Verplichte test-cases per workspace-store:

- `hydrateSnapshot` vult entities + relations.
- Selection cascade: `setActivePbi` reset story+task; `setActiveStory` reset
  task.
- `setActiveProduct(null)` ruimt entities en relations op.
- `applyRealtimeEvent`: pbi/story/task `I|U|D` met sortering en parent-move.
- `applyRealtimeEvent`: event voor ander product wordt genegeerd.
- `applyRealtimeEvent`: unknown entity met matching product → resync trigger.
- Delete-cleanup van actieve selectie.
- `ensureProductLoaded` fetch + sortering.
- Race-safe `ensure*Loaded` met requestId-guard (oude in-flight mag niet
  nieuwere selectie overschrijven).
- `ensureTaskLoaded` zet detail-flag.
- `resyncActiveScopes` triggert ensure-keten met juiste URLs en zet
  `lastResyncAt` + `resyncReason`.
- localStorage restore-hints: `setActiveProduct` en `setActivePbi` schrijven
  de juiste keys.
- Optimistic mutation: rollback herstelt vorige state; settle ruimt pending
  op; SSE-echo wordt idempotent verwerkt.

## Implementatiepad

Migratie van het bestaande systeem (`backlog-store` + `planner-store` +
`selection-store` + `product-store`) naar `product-workspace-store`. Doe dit
in opeenvolgende PRs zodat elke stap in productie te verifiëren is.

### Stap 1 — Skelet opzetten (nieuwe store, nog niet gebruikt)

1. Maak `stores/product-workspace/` met:
   - `types.ts` (entity types, snapshot, realtime event union, ResyncReason).
   - `store.ts` (factory met immer-middleware, alle slices, alle acties).
   - `selectors.ts` (pure functies, useShallow-vriendelijk).
   - `restore.ts` (localStorage hints met validatie).
2. Voeg `applyRealtimeEvent`, `resyncActiveScopes`, `resyncLoadedScopes`,
   `ensure*Loaded` toe — eerst zonder integraties; getest via Vitest.
3. Acceptatie: alle test-cases uit §Testing setup groen. Geen UI-impact nog.

### Stap 2 — Hydration overstappen

4. `BacklogHydrationWrapper` (of equivalent) roept `hydrateSnapshot` aan op de
   nieuwe store i.p.v. `useBacklogStore.setInitialData`.
5. `lib/realtime/use-backlog-realtime.ts` dispatcht naar
   `useProductWorkspaceStore.applyRealtimeEvent` i.p.v. `applyChange` op de
   oude store.
6. Componenten lezen voorlopig nog van de oude stores; nieuwe store loopt
   parallel mee als read-only schaduwkopie. Vergelijk in dev-tools dat de
   inhoud klopt.

### Stap 3 — Componenten omzetten

7. Per panel/component: vervang `useBacklogStore`/`useSelectionStore`/
   `useProductStore` reads door selectors uit de workspace-store + `useShallow`
   waar nodig.
8. Setters (`selectPbi`, `selectStory`, `setCurrentProduct`) vervangen door de
   workspace-acties (`setActivePbi`, `setActiveStory`, `setActiveProduct`).
9. Handle G1 en G2 expliciet: stable empty refs, useShallow voor lijsten.

### Stap 4 — Race-safe en restore-hints

10. `ensure*Loaded` met `activeRequestId`-guard implementeren conform
    §Race-safe loaders.
11. localStorage hints introduceren met `await ensure*Loaded`-chain (zie
    §Restore-hint flow).
12. Verifieer in een staging-omgeving: cold reload met persisted hint
    herstelt selectie zonder fouten.

### Stap 5 — Hidden-tab + reconnect-resync

13. `use-backlog-realtime` aanpassen: niet meer sluiten op `hidden`, blijven
    luisteren. Op `ready` na reconnect: `resyncActiveScopes('reconnect')`.
14. Aparte `useWorkspaceResync()`-hook: trigger `resyncActiveScopes('visible')`
    bij `visibilitychange` van hidden→visible, en bij `online`-event.
15. Doe deze twee in één PR — los gezien zou je oude gedrag kwijtraken zonder
    vangnet.

### Stap 6 — Unknown-event fallback

16. `applyRealtimeEvent` handelt onbekende entity-events af volgens §Unknown
    relevant events. Filter op `isUnknownEntityEvent(payload)`.
17. Verifieer dat job-/worker-/heartbeat-events GEEN refetch triggeren.

### Stap 7 — Cache-headers

18. Zet `cache: 'no-store'` op alle client fetches uit `ensure*Loaded` en
    `resync...`.
19. Bevestig `force-dynamic` op alle read-routes die store-data leveren.

### Stap 8 — Oude stores opruimen

20. Verwijder `stores/backlog-store.ts`, `stores/planner-store.ts`,
    `stores/selection-store.ts`, `stores/product-store.ts` zodra geen
    component er nog op leest. Grep over de hele codebase om verrassingen
    te voorkomen.
21. `stores/products-store.ts` blijft (lijst van producten ≠ active product).

### Stap 9 — Sprint workspace

22. Herhaal het patroon voor `sprint-workspace-store`. Eerste een paar weken
    de product-workspace stabiel laten draaien.

## Acceptatiecriteria

- Een PBI/story/task bestaat als waarheid maar op één plek in de
  client-store.
- Product backlog panels lezen via selectors uit dezelfde workspace-store.
- PBI/story/task SSE-events patchen de store zonder full page refresh.
- Hidden -> visible herstelt gemiste wijzigingen binnen één resync-cyclus.
- Reconnect herstelt gemiste wijzigingen zonder afhankelijkheid van NOTIFY
  replay.
- Directe entity-edits zonder herkenbare delta worden via resync zichtbaar
  (unknown-event filter staat aan, job/worker noise niet meegerekend).
- LocalStorage kan een vorige selectie herstellen, maar nooit ontoegankelijke
  of verwijderde entiteiten forceren; valideer altijd na ensure-load.
- Optimistic DnD heeft rollback en wordt niet dubbel toegepast door SSE
  echoes.
- Read-routes en client fetches leveren geen stale browser/Next cache data.
- Test-suite dekt §Testing setup checklist en draait groen in CI.
- Geen "Maximum update depth exceeded" of "result of getServerSnapshot
  should be cached" warnings in de console (zie §G1 en §G2).
- Auth en `getAccessibleProduct()` blijven ongewijzigd op SSE/read-routes;
  deze rearchitecture raakt alleen client-state, geen serverlaag-security.
