--- 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 storiesById: Record tasksById: Record } relations: { pbiIds: string[] storyIdsByPbi: Record taskIdsByStory: Record } loading: { loadedProductId: string | null loadingProductId: string | null loadedPbiIds: Record loadedStoryIds: Record loadedTaskIds: Record 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 ensurePbiLoaded(pbiId: string, requestId?: string): Promise ensureStoryLoaded(storyId: string, requestId?: string): Promise ensureTaskLoaded(taskId: string, requestId?: string): Promise applyRealtimeEvent(event: ProductRealtimeEvent): void resyncActiveScopes(reason: ResyncReason): Promise resyncLoadedScopes(reason: ResyncReason): Promise 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=` 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[] = [] 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): 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.