From 8001750590dfa971aad87c2a82d35558abef487d Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:39:18 +0100 Subject: [PATCH 1/9] test(notifications): pin read and session ownership --- .../store/notificationStoreOwnership.spec.ts | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/notificationStoreOwnership.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/notificationStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/notificationStoreOwnership.spec.ts new file mode 100644 index 000000000..0248e5c29 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/notificationStoreOwnership.spec.ts @@ -0,0 +1,297 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { notificationsApi } from '../../api/notificationsApi' +import { useNotificationStore } from '../../store/notificationStore' +import { useSessionStore } from '../../store/sessionStore' +import type { + NotificationItem, + NotificationPreference, + UpdateNotificationPreferenceRequest, +} from '../../types/notifications' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../utils/demoMode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isDemoMode: false } +}) + +vi.mock('../../api/notificationsApi', () => ({ + notificationsApi: { + getNotifications: vi.fn(), + markAsRead: vi.fn(), + markAllRead: vi.fn(), + getPreferences: vi.fn(), + updatePreferences: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + refreshToken: vi.fn(), + exchangeOAuthCode: vi.fn(), + exchangeOidcCode: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +vi.mock('../../composables/useErrorMapper', () => ({ + getErrorDisplay: (error: unknown, fallback: string) => ({ + message: error instanceof Error ? error.message : fallback, + }), +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function token(suffix: string): string { + const body = btoa(JSON.stringify({ exp: 1893456000 })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') + return `header.${body}.${suffix}` +} + +function notification(id: string, isRead = false): NotificationItem { + return { + id, + userId: 'user-a', + boardId: null, + type: 'Mention', + cadence: 'Immediate', + title: id, + message: id, + sourceEntityType: 'card', + sourceEntityId: 'card-1', + isRead, + readAt: isRead ? '2026-09-21T00:00:01Z' : null, + createdAt: '2026-09-21T00:00:00Z', + updatedAt: '2026-09-21T00:00:01Z', + } +} + +function preferences( + mentionImmediateEnabled: boolean, +): NotificationPreference { + return { + userId: 'user-a', + inAppChannelEnabled: true, + mentionImmediateEnabled, + mentionDigestEnabled: !mentionImmediateEnabled, + assignmentImmediateEnabled: true, + assignmentDigestEnabled: false, + proposalOutcomeImmediateEnabled: true, + proposalOutcomeDigestEnabled: false, + createdAt: '2026-09-21T00:00:00Z', + updatedAt: '2026-09-21T00:00:01Z', + } +} + +function preferenceRequest( + mentionImmediateEnabled: boolean, +): UpdateNotificationPreferenceRequest { + const value = preferences(mentionImmediateEnabled) + return { + inAppChannelEnabled: value.inAppChannelEnabled, + mentionImmediateEnabled: value.mentionImmediateEnabled, + mentionDigestEnabled: value.mentionDigestEnabled, + assignmentImmediateEnabled: value.assignmentImmediateEnabled, + assignmentDigestEnabled: value.assignmentDigestEnabled, + proposalOutcomeImmediateEnabled: value.proposalOutcomeImmediateEnabled, + proposalOutcomeDigestEnabled: value.proposalOutcomeDigestEnabled, + } +} + +describe('notificationStore async ownership', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'user-a' + session.token = token('old') + store = useNotificationStore() + vi.clearAllMocks() + }) + + it('keeps the newest inbox read when responses settle in reverse order', async () => { + const older = deferred() + const newer = deferred() + vi.mocked(notificationsApi.getNotifications) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise) + + const oldRequest = store.fetchNotifications({ boardId: 'board-old' }) + const newRequest = store.fetchNotifications({ boardId: 'board-new' }) + newer.resolve([notification('new')]) + await newRequest + older.resolve([notification('old')]) + await oldRequest + + expect(store.notifications.map(item => item.id)).toEqual(['new']) + }) + + it('keeps the newest preference read when responses settle in reverse order', async () => { + const older = deferred() + const newer = deferred() + vi.mocked(notificationsApi.getPreferences) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise) + + const oldRequest = store.fetchPreferences() + const newRequest = store.fetchPreferences() + newer.resolve(preferences(false)) + await newRequest + older.resolve(preferences(true)) + await oldRequest + + expect(store.preferences?.mentionImmediateEnabled).toBe(false) + }) + + it('does not let an older inbox read undo a confirmed markAsRead', async () => { + store.notifications = [notification('n-1')] + const read = deferred() + vi.mocked(notificationsApi.getNotifications).mockReturnValue(read.promise) + vi.mocked(notificationsApi.markAsRead).mockResolvedValue(notification('n-1', true)) + + const readRequest = store.fetchNotifications() + await store.markAsRead('n-1') + read.resolve([notification('n-1', false)]) + await readRequest + + expect(store.notifications[0]?.isRead).toBe(true) + }) + + it('does not let an older inbox read undo a confirmed markAllRead', async () => { + store.notifications = [notification('n-1'), notification('n-2')] + const read = deferred() + vi.mocked(notificationsApi.getNotifications).mockReturnValue(read.promise) + vi.mocked(notificationsApi.markAllRead).mockResolvedValue({ markedCount: 2 }) + + const readRequest = store.fetchNotifications() + await store.markAllRead() + read.resolve([notification('n-1'), notification('n-2')]) + await readRequest + + expect(store.notifications.every(item => item.isRead)).toBe(true) + }) + + it('does not let an older preference read undo a confirmed update', async () => { + store.preferences = preferences(true) + const read = deferred() + vi.mocked(notificationsApi.getPreferences).mockReturnValue(read.promise) + vi.mocked(notificationsApi.updatePreferences).mockResolvedValue(preferences(false)) + + const readRequest = store.fetchPreferences() + await store.updatePreferences(preferenceRequest(false)) + read.resolve(preferences(true)) + await readRequest + + expect(store.preferences?.mentionImmediateEnabled).toBe(false) + }) + + it('keeps loading true while an independent notification operation remains pending', async () => { + const inbox = deferred() + const prefs = deferred() + vi.mocked(notificationsApi.getNotifications).mockReturnValue(inbox.promise) + vi.mocked(notificationsApi.getPreferences).mockReturnValue(prefs.promise) + + const inboxRequest = store.fetchNotifications() + const prefsRequest = store.fetchPreferences() + inbox.resolve([]) + await inboxRequest + + expect(store.loading).toBe(true) + + prefs.resolve(preferences(true)) + await prefsRequest + expect(store.loading).toBe(false) + }) + + it('clears both surfaces and rejects old read settlement after token rotation', async () => { + store.notifications = [notification('existing')] + store.preferences = preferences(true) + const inbox = deferred() + const prefs = deferred() + vi.mocked(notificationsApi.getNotifications).mockReturnValue(inbox.promise) + vi.mocked(notificationsApi.getPreferences).mockReturnValue(prefs.promise) + + const inboxRequest = store.fetchNotifications() + const prefsRequest = store.fetchPreferences() + session.token = token('new') + const clearedImmediately = store.notifications.length === 0 && store.preferences === null + + inbox.resolve([notification('old-token')]) + prefs.resolve(preferences(false)) + await Promise.all([inboxRequest, prefsRequest]) + + expect(clearedImmediately).toBe(true) + expect(store.notifications).toEqual([]) + expect(store.preferences).toBeNull() + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('suppresses a stale read failure after token rotation', async () => { + const pending = deferred() + vi.mocked(notificationsApi.getNotifications).mockReturnValue(pending.promise) + const request = store.fetchNotifications() + + session.token = token('new') + pending.reject(new Error('old credential read failed')) + await expect(request).rejects.toThrow('old credential read failed') + + expect(store.notifications).toEqual([]) + expect(store.error).toBeNull() + expect(store.loading).toBe(false) + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('suppresses a stale markAsRead success after token rotation', async () => { + store.notifications = [notification('n-1')] + const pending = deferred() + vi.mocked(notificationsApi.markAsRead).mockReturnValue(pending.promise) + const request = store.markAsRead('n-1') + + session.token = token('new') + pending.resolve(notification('n-1', true)) + await request + + expect(store.notifications).toEqual([]) + expect(store.error).toBeNull() + }) + + it('suppresses a stale preference mutation failure after token rotation', async () => { + const pending = deferred() + vi.mocked(notificationsApi.updatePreferences).mockReturnValue(pending.promise) + const request = store.updatePreferences(preferenceRequest(false)) + + session.token = token('new') + pending.reject(new Error('old credential save failed')) + await expect(request).rejects.toThrow('old credential save failed') + + expect(store.preferences).toBeNull() + expect(store.error).toBeNull() + expect(store.loading).toBe(false) + expect(toastMocks.error).not.toHaveBeenCalled() + }) +}) From 585d28bd7e8d1d4f71d8b0ee01b2c097106732c9 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:43:30 +0100 Subject: [PATCH 2/9] fix(notifications): bind reads to confirmed writes and credentials --- .../src/store/notificationStore.ts | 223 +++++++++++++++--- 1 file changed, 187 insertions(+), 36 deletions(-) diff --git a/frontend/taskdeck-web/src/store/notificationStore.ts b/frontend/taskdeck-web/src/store/notificationStore.ts index dbf2e0b73..5fd3296d1 100644 --- a/frontend/taskdeck-web/src/store/notificationStore.ts +++ b/frontend/taskdeck-web/src/store/notificationStore.ts @@ -1,7 +1,8 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, watch } from 'vue' import { notificationsApi } from '../api/notificationsApi' import { useToastStore } from './toastStore' +import { useSessionStore } from './sessionStore' import { isDemoMode, DemoModeError } from '../utils/demoMode' import { getErrorDisplay } from '../composables/useErrorMapper' import type { @@ -13,12 +14,137 @@ import type { export const useNotificationStore = defineStore('notifications', () => { const toast = useToastStore() + const session = useSessionStore() const notifications = ref([]) const preferences = ref(null) const loading = ref(false) const error = ref(null) + type ReadLane = 'notifications' | 'preferences' + + interface OperationOwner { + epoch: number + token: symbol + ownsLoading: boolean + } + + interface ReadOwner extends OperationOwner { + observedMutationGeneration: number + } + + let sessionEpoch = 0 + let notificationMutationGeneration = 0 + let preferenceMutationGeneration = 0 + const activeLoadingOperations = new Set() + const readOwners = new Map() + + function syncLoading(): void { + loading.value = activeLoadingOperations.size > 0 + } + + function clearError(): void { + error.value = null + } + + function beginOperation( + label: string, + options: { ownsLoading?: boolean; clearExistingError?: boolean } = {}, + ): OperationOwner { + const owner = { + epoch: sessionEpoch, + token: Symbol(label), + ownsLoading: options.ownsLoading ?? false, + } + if (owner.ownsLoading) activeLoadingOperations.add(owner.token) + if (options.clearExistingError ?? false) clearError() + syncLoading() + return owner + } + + function ownsSession(owner: OperationOwner): boolean { + return owner.epoch === sessionEpoch + } + + function finishOperation(owner: OperationOwner): void { + if (!ownsSession(owner)) return + if (owner.ownsLoading) activeLoadingOperations.delete(owner.token) + syncLoading() + } + + function mutationGeneration(lane: ReadLane): number { + return lane === 'notifications' + ? notificationMutationGeneration + : preferenceMutationGeneration + } + + function beginRead(lane: ReadLane): ReadOwner { + const previous = readOwners.get(lane) + if (previous?.epoch === sessionEpoch && previous.ownsLoading) { + activeLoadingOperations.delete(previous.token) + } + + const operation = beginOperation(`read:${lane}`, { + ownsLoading: true, + clearExistingError: true, + }) + const owner = { + ...operation, + observedMutationGeneration: mutationGeneration(lane), + } + readOwners.set(lane, owner) + return owner + } + + function ownsRead(lane: ReadLane, owner: ReadOwner): boolean { + const current = readOwners.get(lane) + return ownsSession(owner) + && current?.token === owner.token + && owner.observedMutationGeneration === mutationGeneration(lane) + } + + function finishRead(lane: ReadLane, owner: ReadOwner): void { + if (readOwners.get(lane)?.token === owner.token) readOwners.delete(lane) + finishOperation(owner) + } + + function invalidateRead(lane: ReadLane): void { + const owner = readOwners.get(lane) + if (owner?.epoch === sessionEpoch && owner.ownsLoading) { + activeLoadingOperations.delete(owner.token) + } + readOwners.delete(lane) + syncLoading() + } + + function recordNotificationMutation(): void { + notificationMutationGeneration += 1 + invalidateRead('notifications') + } + + function recordPreferenceMutation(): void { + preferenceMutationGeneration += 1 + invalidateRead('preferences') + } + + function resetForSession(): void { + sessionEpoch += 1 + notificationMutationGeneration = 0 + preferenceMutationGeneration = 0 + activeLoadingOperations.clear() + readOwners.clear() + notifications.value = [] + preferences.value = null + loading.value = false + clearError() + } + + watch( + () => [session.userId, session.token, session.isAuthenticated, session.isDemo], + resetForSession, + { flush: 'sync' }, + ) + function guardDemoMutation(): never | void { if (isDemoMode) { toast.info('This action is view-only in demo mode.') @@ -28,46 +154,59 @@ export const useNotificationStore = defineStore('notifications', () => { async function fetchNotifications(query?: NotificationQuery) { if (isDemoMode) { - loading.value = true - error.value = null + invalidateRead('notifications') + clearError() notifications.value = [] - loading.value = false return } + + const owner = beginRead('notifications') try { - loading.value = true - error.value = null - notifications.value = await notificationsApi.getNotifications(query) + const result = await notificationsApi.getNotifications(query) + if (!ownsRead('notifications', owner)) return + notifications.value = result } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to load notifications').message - error.value = msg - toast.error(msg) + if (ownsRead('notifications', owner)) { + const msg = getErrorDisplay(e, 'Failed to load notifications').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishRead('notifications', owner) } } async function markAsRead(notificationId: string) { guardDemoMutation() + const owner = beginOperation(`mark-read:${notificationId}`) try { const updated = await notificationsApi.markAsRead(notificationId) + if (!ownsSession(owner)) return updated + + recordNotificationMutation() notifications.value = notifications.value.map((item) => ( item.id === notificationId ? updated : item )) return updated } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to mark notification as read').message - error.value = msg - toast.error(msg) + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to mark notification as read').message + error.value = msg + toast.error(msg) + } throw e } } async function markAllRead(boardId?: string) { guardDemoMutation() + const owner = beginOperation(`mark-all-read:${boardId ?? 'all'}`) try { const result = await notificationsApi.markAllRead(boardId) + if (!ownsSession(owner)) return result + + recordNotificationMutation() notifications.value = notifications.value.map((item) => { if (boardId && item.boardId !== boardId) return item return { @@ -78,51 +217,63 @@ export const useNotificationStore = defineStore('notifications', () => { }) return result } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to mark all notifications as read').message - error.value = msg - toast.error(msg) + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to mark all notifications as read').message + error.value = msg + toast.error(msg) + } throw e } } async function fetchPreferences() { if (isDemoMode) { - loading.value = true - error.value = null + invalidateRead('preferences') + clearError() preferences.value = null - loading.value = false return preferences.value } + + const owner = beginRead('preferences') try { - loading.value = true - error.value = null - preferences.value = await notificationsApi.getPreferences() - return preferences.value + const result = await notificationsApi.getPreferences() + if (ownsRead('preferences', owner)) preferences.value = result + return result } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to load notification preferences').message - error.value = msg - toast.error(msg) + if (ownsRead('preferences', owner)) { + const msg = getErrorDisplay(e, 'Failed to load notification preferences').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishRead('preferences', owner) } } async function updatePreferences(dto: UpdateNotificationPreferenceRequest) { guardDemoMutation() + const owner = beginOperation('update-preferences', { + ownsLoading: true, + clearExistingError: true, + }) try { - loading.value = true - error.value = null - preferences.value = await notificationsApi.updatePreferences(dto) + const updated = await notificationsApi.updatePreferences(dto) + if (!ownsSession(owner)) return updated + + recordPreferenceMutation() + preferences.value = updated toast.success('Notification preferences saved') - return preferences.value + return updated } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to save notification preferences').message - error.value = msg - toast.error(msg) + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to save notification preferences').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } From 49b903a61a744061f04511377a97f2807dfeed1b Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:43:49 +0100 Subject: [PATCH 3/9] docs(notifications): record ownership evidence --- ...2026-09-21-notification-store-ownership.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/analysis/2026-09-21-notification-store-ownership.md diff --git a/docs/analysis/2026-09-21-notification-store-ownership.md b/docs/analysis/2026-09-21-notification-store-ownership.md new file mode 100644 index 000000000..9df71b7ad --- /dev/null +++ b/docs/analysis/2026-09-21-notification-store-ownership.md @@ -0,0 +1,33 @@ +# Notification store request and credential ownership + +Status: draft PR #3340, 2026-09-21. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`. + +## Reproduced defects + +`notificationStore` previously let every inbox and preference read replace its shared surface. Confirmed mark-read and preference writes did not invalidate reads that began against an older snapshot. Inbox reads, preference reads and preference writes also assigned one loading Boolean directly, so the first settlement could clear another operation's busy state. + +The store had no credential lifetime. Work started under one account or token could settle after logout, login or refresh and populate the replacement notification surfaces or emit stale error/toast UI. + +## Contract + +Inbox and preferences have independent latest-read owners. Each owner carries a unique token, the current credential epoch and the mutation generation observed at request start. A newer request retires only the previous read in the same lane. + +Successful `markAsRead` and `markAllRead` advance the inbox mutation generation and retire an older inbox read. A successful preference update does the same for preference reads. Current loading is derived from active loading-owner tokens rather than whichever call settles first. + +The store watches user identity, token, authenticated state and demo state synchronously. Any replacement advances the epoch, retires owners and clears notifications, preferences, error and loading. Stale work still resolves or rejects to its original caller but cannot patch replacement state, toast or clear current loading. + +Mutation serialization, realtime arrival versus refresh, and the reminder/email feature work in #2010 remain outside this slice. + +## Test-first evidence + +The committed test-only head is `8001750590dfa971aad87c2a82d35558abef487d`. Its hosted workflows were still queued when the production correction was published and may be superseded; no canonical RED result is claimed unless a completed artifact is later inspected. + +A dependency-free supplemental runner transpiled and executed the actual production module with only its Pinia/Vue/API/session boundaries stubbed. Against unchanged `main`, all ten ownership schedules failed (**0/10 passed**). Against the correction, all ten passed (**10/10**). + +The schedules cover reverse inbox and preference reads; stale reads after mark-one, mark-all and preference writes; independent loading ownership; token-rotation clearing; stale read failure; stale mark success; and stale preference-write failure. + +## Verification and remaining gates + +The corrected production module transpiles under TypeScript 5.8.3 with zero diagnostics. Canonical Pinia/Vitest, lint, project typecheck, build, exact-head hosted CI and independent review are still required. Existing notification-store, realtime, integration, demo and view suites must remain green. + +This is client-state integrity, not a server-authorization claim or transport cancellation guarantee. No merge, release or deployment qualification is claimed by this note. From 10d48f732725ed8ee9a2557ac39ccf7b7a7958d5 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:49:43 +0100 Subject: [PATCH 4/9] test(notifications): preserve loaded data across token refresh --- .../store/notificationStoreOwnership.spec.ts | 57 +++++++++++++++---- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/store/notificationStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/notificationStoreOwnership.spec.ts index 0248e5c29..5222fad31 100644 --- a/frontend/taskdeck-web/src/tests/store/notificationStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/notificationStoreOwnership.spec.ts @@ -227,7 +227,7 @@ describe('notificationStore async ownership', () => { expect(store.loading).toBe(false) }) - it('clears both surfaces and rejects old read settlement after token rotation', async () => { + it('preserves both surfaces and rejects old read settlement after token rotation', async () => { store.notifications = [notification('existing')] store.preferences = preferences(true) const inbox = deferred() @@ -238,20 +238,24 @@ describe('notificationStore async ownership', () => { const inboxRequest = store.fetchNotifications() const prefsRequest = store.fetchPreferences() session.token = token('new') - const clearedImmediately = store.notifications.length === 0 && store.preferences === null + + expect(store.notifications.map(item => item.id)).toEqual(['existing']) + expect(store.preferences?.mentionImmediateEnabled).toBe(true) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() inbox.resolve([notification('old-token')]) prefs.resolve(preferences(false)) await Promise.all([inboxRequest, prefsRequest]) - expect(clearedImmediately).toBe(true) - expect(store.notifications).toEqual([]) - expect(store.preferences).toBeNull() + expect(store.notifications.map(item => item.id)).toEqual(['existing']) + expect(store.preferences?.mentionImmediateEnabled).toBe(true) expect(store.loading).toBe(false) expect(store.error).toBeNull() }) - it('suppresses a stale read failure after token rotation', async () => { + it('suppresses a stale read failure after token rotation while preserving inbox data', async () => { + store.notifications = [notification('existing')] const pending = deferred() vi.mocked(notificationsApi.getNotifications).mockReturnValue(pending.promise) const request = store.fetchNotifications() @@ -260,13 +264,13 @@ describe('notificationStore async ownership', () => { pending.reject(new Error('old credential read failed')) await expect(request).rejects.toThrow('old credential read failed') - expect(store.notifications).toEqual([]) + expect(store.notifications.map(item => item.id)).toEqual(['existing']) expect(store.error).toBeNull() expect(store.loading).toBe(false) expect(toastMocks.error).not.toHaveBeenCalled() }) - it('suppresses a stale markAsRead success after token rotation', async () => { + it('suppresses a stale markAsRead success after token rotation while preserving inbox data', async () => { store.notifications = [notification('n-1')] const pending = deferred() vi.mocked(notificationsApi.markAsRead).mockReturnValue(pending.promise) @@ -276,11 +280,14 @@ describe('notificationStore async ownership', () => { pending.resolve(notification('n-1', true)) await request - expect(store.notifications).toEqual([]) + expect(store.notifications.map(item => ({ id: item.id, isRead: item.isRead }))).toEqual([ + { id: 'n-1', isRead: false }, + ]) expect(store.error).toBeNull() }) - it('suppresses a stale preference mutation failure after token rotation', async () => { + it('suppresses a stale preference mutation failure after token rotation while preserving preferences', async () => { + store.preferences = preferences(true) const pending = deferred() vi.mocked(notificationsApi.updatePreferences).mockReturnValue(pending.promise) const request = store.updatePreferences(preferenceRequest(false)) @@ -289,9 +296,37 @@ describe('notificationStore async ownership', () => { pending.reject(new Error('old credential save failed')) await expect(request).rejects.toThrow('old credential save failed') - expect(store.preferences).toBeNull() + expect(store.preferences?.mentionImmediateEnabled).toBe(true) expect(store.error).toBeNull() expect(store.loading).toBe(false) expect(toastMocks.error).not.toHaveBeenCalled() }) + + it('clears both surfaces on identity replacement and suppresses old work', async () => { + store.notifications = [notification('existing')] + store.preferences = preferences(true) + const inbox = deferred() + const save = deferred() + vi.mocked(notificationsApi.getNotifications).mockReturnValue(inbox.promise) + vi.mocked(notificationsApi.updatePreferences).mockReturnValue(save.promise) + + const inboxRequest = store.fetchNotifications() + const saveRequest = store.updatePreferences(preferenceRequest(false)) + session.userId = 'user-b' + + expect(store.notifications).toEqual([]) + expect(store.preferences).toBeNull() + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + + inbox.resolve([notification('old-user')]) + save.resolve(preferences(false)) + await Promise.all([inboxRequest, saveRequest]) + + expect(store.notifications).toEqual([]) + expect(store.preferences).toBeNull() + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(toastMocks.success).not.toHaveBeenCalled() + }) }) From 53778525796d972bce351df5467c550fc269d1ae Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:17:18 +0100 Subject: [PATCH 5/9] fix(notifications): preserve loaded state across token refresh --- .../src/store/notificationStore.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/frontend/taskdeck-web/src/store/notificationStore.ts b/frontend/taskdeck-web/src/store/notificationStore.ts index 5fd3296d1..e89bfd70e 100644 --- a/frontend/taskdeck-web/src/store/notificationStore.ts +++ b/frontend/taskdeck-web/src/store/notificationStore.ts @@ -127,24 +127,34 @@ export const useNotificationStore = defineStore('notifications', () => { invalidateRead('preferences') } - function resetForSession(): void { + function invalidateOperations(): void { sessionEpoch += 1 notificationMutationGeneration = 0 preferenceMutationGeneration = 0 activeLoadingOperations.clear() readOwners.clear() - notifications.value = [] - preferences.value = null loading.value = false clearError() } + function resetForSession(): void { + invalidateOperations() + notifications.value = [] + preferences.value = null + } + watch( - () => [session.userId, session.token, session.isAuthenticated, session.isDemo], + () => [session.userId, session.isAuthenticated, session.isDemo], resetForSession, { flush: 'sync' }, ) + watch( + () => session.token, + invalidateOperations, + { flush: 'sync' }, + ) + function guardDemoMutation(): never | void { if (isDemoMode) { toast.info('This action is view-only in demo mode.') From 5831a47d1ba85c735de0937b9e8f0704d6d03d1a Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:21:24 +0100 Subject: [PATCH 6/9] docs(notifications): preserve loaded state on token refresh --- ...2026-09-21-notification-store-ownership.md | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/analysis/2026-09-21-notification-store-ownership.md b/docs/analysis/2026-09-21-notification-store-ownership.md index 9df71b7ad..cb856e61c 100644 --- a/docs/analysis/2026-09-21-notification-store-ownership.md +++ b/docs/analysis/2026-09-21-notification-store-ownership.md @@ -4,30 +4,35 @@ Status: draft PR #3340, 2026-09-21. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4 ## Reproduced defects -`notificationStore` previously let every inbox and preference read replace its shared surface. Confirmed mark-read and preference writes did not invalidate reads that began against an older snapshot. Inbox reads, preference reads and preference writes also assigned one loading Boolean directly, so the first settlement could clear another operation's busy state. +`notificationStore` previously let every inbox and preference read replace its shared surface. Confirmed mark-read and preference writes did not invalidate reads that began against an older snapshot. Inbox reads, preference reads, and preference writes also assigned one loading Boolean directly, so the first settlement could clear another operation's busy state. -The store had no credential lifetime. Work started under one account or token could settle after logout, login or refresh and populate the replacement notification surfaces or emit stale error/toast UI. +The initial lifecycle correction invalidated old work, but treated a same-user token refresh as a full data reset. A successful session extension could therefore blank an unchanged inbox route or detach the mounted preference form from its loaded store value. ## Contract -Inbox and preferences have independent latest-read owners. Each owner carries a unique token, the current credential epoch and the mutation generation observed at request start. A newer request retires only the previous read in the same lane. +Inbox and preferences have independent latest-read owners. Each owner carries a unique token, the current lifecycle epoch, and the mutation generation observed at request start. A newer request retires only the previous read in the same lane. -Successful `markAsRead` and `markAllRead` advance the inbox mutation generation and retire an older inbox read. A successful preference update does the same for preference reads. Current loading is derived from active loading-owner tokens rather than whichever call settles first. +Successful `markAsRead` and `markAllRead` advance the inbox mutation generation and retire older inbox reads. A successful preference update does the same for preference reads. Loading is derived from active loading-owner tokens rather than whichever call settles first. -The store watches user identity, token, authenticated state and demo state synchronously. Any replacement advances the epoch, retires owners and clears notifications, preferences, error and loading. Stale work still resolves or rejects to its original caller but cannot patch replacement state, toast or clear current loading. +User identity, authentication, or demo-session replacement advances the epoch, retires work, and clears notifications and preferences. Token-only rotation advances the same operation epoch and clears transient loading/error ownership, but preserves loaded notifications and preferences for the unchanged user and route. Stale work still resolves or rejects to its original caller but cannot patch state, toast, or clear current loading. -Mutation serialization, realtime arrival versus refresh, and the reminder/email feature work in #2010 remain outside this slice. +Mutation serialization, realtime arrival versus refresh, and reminder/email work in #2010 remain outside this slice. ## Test-first evidence -The committed test-only head is `8001750590dfa971aad87c2a82d35558abef487d`. Its hosted workflows were still queued when the production correction was published and may be superseded; no canonical RED result is claimed unless a completed artifact is later inspected. +The initial supplemental actual-module suite changed from **0/10 passing on `main`** to **10/10 passing** after the first ownership correction. -A dependency-free supplemental runner transpiled and executed the actual production module with only its Pinia/Vue/API/session boundaries stubbed. Against unchanged `main`, all ten ownership schedules failed (**0/10 passed**). Against the correction, all ten passed (**10/10**). +Review-regression head `10d48f732725ed8ee9a2557ac39ccf7b7a7958d5` ran canonical Node 24 frontend qualification on Ubuntu and Windows. Lint, typecheck, production build, and PWA validation passed on both platforms. Ubuntu JUnit recorded **7,161 tests, exactly 4 failures, 0 errors**; every failure was a new token-refresh preservation case: -The schedules cover reverse inbox and preference reads; stale reads after mark-one, mark-all and preference writes; independent loading ownership; token-rotation clearing; stale read failure; stale mark success; and stale preference-write failure. +1. preserve inbox and preferences while old reads settle; +2. preserve inbox while an old read fails; +3. preserve inbox while an old mark-read succeeds; +4. preserve preferences while an old save fails. -## Verification and remaining gates +No unrelated frontend test failed. -The corrected production module transpiles under TypeScript 5.8.3 with zero diagnostics. Canonical Pinia/Vitest, lint, project typecheck, build, exact-head hosted CI and independent review are still required. Existing notification-store, realtime, integration, demo and view suites must remain green. +## Remaining gates -This is client-state integrity, not a server-authorization claim or transport cancellation guarantee. No merge, release or deployment qualification is claimed by this note. +The production correction splits token-only operation invalidation from full identity reset. Exact-head canonical tests, complete Required CI, Extended, Self-Test, and fresh-context review remain required. Existing notification-store, realtime, integration, demo, and view suites must remain green. + +This is client-state integrity, not a server-authorization claim or transport cancellation guarantee. No merge, release, or deployment qualification is claimed. From 660362c9546b51f9996659be3382ac4b6d67f424 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:32:34 +0100 Subject: [PATCH 7/9] test(notifications): retry empty initial reads after token refresh --- .../store/notificationStoreOwnership.spec.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/notificationStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/notificationStoreOwnership.spec.ts index 5222fad31..7013958f0 100644 --- a/frontend/taskdeck-web/src/tests/store/notificationStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/notificationStoreOwnership.spec.ts @@ -302,6 +302,47 @@ describe('notificationStore async ownership', () => { expect(toastMocks.error).not.toHaveBeenCalled() }) + it('retries empty initial inbox and preference reads after same-user token rotation', async () => { + const oldInbox = deferred() + const freshInbox = deferred() + const oldPreferences = deferred() + const freshPreferences = deferred() + vi.mocked(notificationsApi.getNotifications) + .mockReturnValueOnce(oldInbox.promise) + .mockReturnValueOnce(freshInbox.promise) + vi.mocked(notificationsApi.getPreferences) + .mockReturnValueOnce(oldPreferences.promise) + .mockReturnValueOnce(freshPreferences.promise) + + const inboxRequest = store.fetchNotifications({ boardId: 'board-a' }) + const preferencesRequest = store.fetchPreferences() + session.token = token('new') + + expect(notificationsApi.getNotifications).toHaveBeenCalledTimes(2) + expect(notificationsApi.getPreferences).toHaveBeenCalledTimes(2) + expect(store.notifications).toEqual([]) + expect(store.preferences).toBeNull() + expect(store.loading).toBe(true) + + oldInbox.resolve([notification('old-token')]) + oldPreferences.resolve(preferences(false)) + await Promise.all([inboxRequest, preferencesRequest]) + + expect(store.notifications).toEqual([]) + expect(store.preferences).toBeNull() + expect(store.loading).toBe(true) + expect(store.error).toBeNull() + + freshInbox.resolve([notification('fresh-token')]) + freshPreferences.resolve(preferences(true)) + await vi.waitFor(() => { + expect(store.notifications.map(item => item.id)).toEqual(['fresh-token']) + expect(store.preferences?.mentionImmediateEnabled).toBe(true) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + }) + it('clears both surfaces on identity replacement and suppresses old work', async () => { store.notifications = [notification('existing')] store.preferences = preferences(true) From 2192edf4e0717998b7e1c24236546902d6a9229a Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:44:43 +0100 Subject: [PATCH 8/9] fix(notifications): retry empty active reads after token refresh --- .../src/store/notificationStore.ts | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/frontend/taskdeck-web/src/store/notificationStore.ts b/frontend/taskdeck-web/src/store/notificationStore.ts index e89bfd70e..a7d82a0ae 100644 --- a/frontend/taskdeck-web/src/store/notificationStore.ts +++ b/frontend/taskdeck-web/src/store/notificationStore.ts @@ -22,6 +22,7 @@ export const useNotificationStore = defineStore('notifications', () => { const error = ref(null) type ReadLane = 'notifications' | 'preferences' + type ReadRetry = () => Promise interface OperationOwner { epoch: number @@ -38,6 +39,7 @@ export const useNotificationStore = defineStore('notifications', () => { let preferenceMutationGeneration = 0 const activeLoadingOperations = new Set() const readOwners = new Map() + const readRetries = new Map() function syncLoading(): void { loading.value = activeLoadingOperations.size > 0 @@ -78,7 +80,7 @@ export const useNotificationStore = defineStore('notifications', () => { : preferenceMutationGeneration } - function beginRead(lane: ReadLane): ReadOwner { + function beginRead(lane: ReadLane, retry: ReadRetry): ReadOwner { const previous = readOwners.get(lane) if (previous?.epoch === sessionEpoch && previous.ownsLoading) { activeLoadingOperations.delete(previous.token) @@ -93,6 +95,7 @@ export const useNotificationStore = defineStore('notifications', () => { observedMutationGeneration: mutationGeneration(lane), } readOwners.set(lane, owner) + readRetries.set(lane, retry) return owner } @@ -104,7 +107,10 @@ export const useNotificationStore = defineStore('notifications', () => { } function finishRead(lane: ReadLane, owner: ReadOwner): void { - if (readOwners.get(lane)?.token === owner.token) readOwners.delete(lane) + if (readOwners.get(lane)?.token === owner.token) { + readOwners.delete(lane) + readRetries.delete(lane) + } finishOperation(owner) } @@ -114,6 +120,7 @@ export const useNotificationStore = defineStore('notifications', () => { activeLoadingOperations.delete(owner.token) } readOwners.delete(lane) + readRetries.delete(lane) syncLoading() } @@ -133,10 +140,32 @@ export const useNotificationStore = defineStore('notifications', () => { preferenceMutationGeneration = 0 activeLoadingOperations.clear() readOwners.clear() + readRetries.clear() loading.value = false clearError() } + function retryEmptyActiveReads(): void { + const notificationRetry = readOwners.has('notifications') && notifications.value.length === 0 + ? readRetries.get('notifications') + : undefined + const preferenceRetry = readOwners.has('preferences') && preferences.value === null + ? readRetries.get('preferences') + : undefined + + invalidateOperations() + if (notificationRetry) { + void notificationRetry().catch(() => { + // The retried store action owns current error/toast state. + }) + } + if (preferenceRetry) { + void preferenceRetry().catch(() => { + // The retried store action owns current error/toast state. + }) + } + } + function resetForSession(): void { invalidateOperations() notifications.value = [] @@ -151,7 +180,7 @@ export const useNotificationStore = defineStore('notifications', () => { watch( () => session.token, - invalidateOperations, + retryEmptyActiveReads, { flush: 'sync' }, ) @@ -170,7 +199,7 @@ export const useNotificationStore = defineStore('notifications', () => { return } - const owner = beginRead('notifications') + const owner = beginRead('notifications', () => fetchNotifications(query)) try { const result = await notificationsApi.getNotifications(query) if (!ownsRead('notifications', owner)) return @@ -244,7 +273,7 @@ export const useNotificationStore = defineStore('notifications', () => { return preferences.value } - const owner = beginRead('preferences') + const owner = beginRead('preferences', fetchPreferences) try { const result = await notificationsApi.getPreferences() if (ownsRead('preferences', owner)) preferences.value = result From 4f8134d6c6ecd840041c0127371798e0c412f06a Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:50:34 +0100 Subject: [PATCH 9/9] docs(notifications): record empty-read retry contract --- ...2026-09-21-notification-store-ownership.md | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/analysis/2026-09-21-notification-store-ownership.md b/docs/analysis/2026-09-21-notification-store-ownership.md index cb856e61c..732a491aa 100644 --- a/docs/analysis/2026-09-21-notification-store-ownership.md +++ b/docs/analysis/2026-09-21-notification-store-ownership.md @@ -4,17 +4,26 @@ Status: draft PR #3340, 2026-09-21. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4 ## Reproduced defects -`notificationStore` previously let every inbox and preference read replace its shared surface. Confirmed mark-read and preference writes did not invalidate reads that began against an older snapshot. Inbox reads, preference reads, and preference writes also assigned one loading Boolean directly, so the first settlement could clear another operation's busy state. +`notificationStore` previously let every inbox and preference read replace its shared surface. Confirmed mark-read and preference writes did not invalidate reads that began against an older snapshot. Inbox reads, preference reads and preference writes also assigned one loading Boolean directly, so the first settlement could clear another operation's busy state. -The initial lifecycle correction invalidated old work, but treated a same-user token refresh as a full data reset. A successful session extension could therefore blank an unchanged inbox route or detach the mounted preference form from its loaded store value. +The first lifecycle correction treated same-user token refresh as a full data reset and could blank an unchanged inbox or detach the mounted preference form from its store value. The preservation correction then exposed a second boundary: when refresh happened during an empty initial read, the old owner was retired but the unchanged route did not remount or refetch. ## Contract -Inbox and preferences have independent latest-read owners. Each owner carries a unique token, the current lifecycle epoch, and the mutation generation observed at request start. A newer request retires only the previous read in the same lane. +Inbox and preferences have independent latest-read owners. Each owner carries a unique token, the current lifecycle epoch and the mutation generation observed at request start. A newer request retires only the previous read in the same lane. Successful `markAsRead` and `markAllRead` advance the inbox mutation generation and retire older inbox reads. A successful preference update does the same for preference reads. Loading is derived from active loading-owner tokens rather than whichever call settles first. -User identity, authentication, or demo-session replacement advances the epoch, retires work, and clears notifications and preferences. Token-only rotation advances the same operation epoch and clears transient loading/error ownership, but preserves loaded notifications and preferences for the unchanged user and route. Stale work still resolves or rejects to its original caller but cannot patch state, toast, or clear current loading. +User identity, authentication or demo-session replacement advances the epoch, retires work and clears notifications and preferences. + +A token-only rotation: + +- preserves settled notifications and preferences; +- suppresses old-token success, failure, toast and loading settlement; +- restarts an active inbox read only while the inbox is still empty; +- restarts an active preference read only while preferences are still null; +- retains the exact inbox query captured by the active read; +- never replays mark-read, mark-all or preference-update mutations. Mutation serialization, realtime arrival versus refresh, and reminder/email work in #2010 remain outside this slice. @@ -22,17 +31,19 @@ Mutation serialization, realtime arrival versus refresh, and reminder/email work The initial supplemental actual-module suite changed from **0/10 passing on `main`** to **10/10 passing** after the first ownership correction. -Review-regression head `10d48f732725ed8ee9a2557ac39ccf7b7a7958d5` ran canonical Node 24 frontend qualification on Ubuntu and Windows. Lint, typecheck, production build, and PWA validation passed on both platforms. Ubuntu JUnit recorded **7,161 tests, exactly 4 failures, 0 errors**; every failure was a new token-refresh preservation case: +Review-regression head `10d48f732725ed8ee9a2557ac39ccf7b7a7958d5` ran canonical Node 24 frontend qualification on Ubuntu and Windows. Lint, typecheck, production build and PWA validation passed on both platforms. Ubuntu JUnit recorded **7,161 tests, exactly 4 failures, 0 errors**, all loaded-state preservation cases. -1. preserve inbox and preferences while old reads settle; -2. preserve inbox while an old read fails; -3. preserve inbox while an old mark-read succeeds; -4. preserve preferences while an old save fails. +Issue #3352 added test-only head `660362c9546b51f9996659be3382ac4b6d67f424`, covering token rotation while inbox and preferences are still empty. A dependency-free runner transpiled and executed the actual production module: -No unrelated frontend test failed. +- before the retry correction: each read API was called once and loading became false after rotation; +- after the correction: each read API was called twice, old-token settlement was suppressed and fresh-token inbox/preferences populated independently. + +Hosted exact-head qualification remains authoritative; the supplemental runner does not replace it. ## Remaining gates -The production correction splits token-only operation invalidation from full identity reset. Exact-head canonical tests, complete Required CI, Extended, Self-Test, and fresh-context review remain required. Existing notification-store, realtime, integration, demo, and view suites must remain green. +Current production correction: `2192edf4e0717998b7e1c24236546902d6a9229a` before this documentation commit. + +Exact final-head lint, typecheck, production build, complete Vitest on Ubuntu and Windows, Required CI, Extended, Self-Test and fresh-context review remain required. Existing notification-store, realtime, integration, demo and view suites must remain green. Stacked preference-order PR #3343 must later be reconciled to this corrected parent and requalified. -This is client-state integrity, not a server-authorization claim or transport cancellation guarantee. No merge, release, or deployment qualification is claimed. +This is client-state integrity, not a server-authorization claim or transport cancellation guarantee. No merge, release or deployment qualification is claimed.