From ee42cb53e1b2fc4b7c912d1849dc2e9bc8abdd8e Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:01:03 +0100 Subject: [PATCH 01/12] test(permissions): reproduce read and session races --- .../store/permissionsStoreOwnership.spec.ts | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts new file mode 100644 index 000000000..46ec3e60f --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { boardAccessApi } from '../../api/boardAccessApi' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useSessionStore } from '../../store/sessionStore' +import type { BoardAccess } from '../../types/access' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../api/boardAccessApi', () => ({ + boardAccessApi: { + getAccess: vi.fn(), + grantAccess: vi.fn(), + updateAccess: vi.fn(), + revokeAccess: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +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 access(overrides: Partial = {}): BoardAccess { + return { + id: 'access-1', + boardId: 'board-1', + userId: 'user-1', + role: 'Owner', + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + ...overrides, + } +} + +describe('permissionsStore async ownership', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'owner-1' + store = usePermissionsStore() + vi.clearAllMocks() + }) + + it('does not let a read started before revoke reintroduce the revoked entry', async () => { + const owner = access({ id: 'owner', userId: 'owner-1', role: 'Owner' }) + const viewer = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) + store.boardAccess.set('board-1', [owner, viewer]) + const read = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(read.promise) + vi.mocked(boardAccessApi.revokeAccess).mockResolvedValue() + + const readRequest = store.fetchBoardAccess('board-1') + await store.revokeAccess('board-1', 'viewer') + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner']) + + read.resolve([owner, viewer]) + await readRequest + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner']) + }) + + it('does not let a read started before a role update restore the old role', async () => { + const oldEntry = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) + const updatedEntry = access({ id: 'viewer', userId: 'viewer-1', role: 'Admin' }) + store.boardAccess.set('board-1', [oldEntry]) + const read = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(read.promise) + vi.mocked(boardAccessApi.updateAccess).mockResolvedValue(updatedEntry) + + const readRequest = store.fetchBoardAccess('board-1') + await store.updateAccess('board-1', 'viewer', { role: 'Admin' }) + read.resolve([oldEntry]) + await readRequest + + expect(store.boardAccess.get('board-1')?.[0].role).toBe('Admin') + }) + + it('keeps the newest same-board read when responses settle in reverse order', async () => { + const older = deferred() + const newer = deferred() + vi.mocked(boardAccessApi.getAccess) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise) + + const oldRequest = store.fetchBoardAccess('board-1') + const newRequest = store.fetchBoardAccess('board-1') + newer.resolve([access({ id: 'new', role: 'Admin' })]) + await newRequest + older.resolve([access({ id: 'old', role: 'Viewer' })]) + await oldRequest + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['new']) + }) + + it('keeps loading true while independent board reads remain active', async () => { + const boardOne = deferred() + const boardTwo = deferred() + vi.mocked(boardAccessApi.getAccess) + .mockReturnValueOnce(boardOne.promise) + .mockReturnValueOnce(boardTwo.promise) + + const first = store.fetchBoardAccess('board-1') + const second = store.fetchBoardAccess('board-2') + expect(store.loading).toBe(true) + + boardOne.resolve([access({ boardId: 'board-1' })]) + await first + expect(store.loading).toBe(true) + + boardTwo.resolve([access({ id: 'board-2-owner', boardId: 'board-2' })]) + await second + expect(store.loading).toBe(false) + }) + + it('invalidates an old read across logout and login as the same user id', async () => { + const read = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(read.promise) + const request = store.fetchBoardAccess('board-1') + + session.userId = null + session.userId = 'owner-1' + read.resolve([access({ id: 'old-session' })]) + await request + + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('does not publish a mutation that settles after the session changes', async () => { + const pendingGrant = deferred() + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(pendingGrant.promise) + const request = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + + session.userId = null + session.userId = 'other-user' + pendingGrant.resolve(access({ id: 'old-session-grant', userId: 'viewer-1', role: 'Viewer' })) + await request + + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(toastMocks.success).not.toHaveBeenCalled() + }) + + it('suppresses a stale read failure after a confirmed mutation', async () => { + const viewer = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) + store.boardAccess.set('board-1', [viewer]) + const read = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(read.promise) + vi.mocked(boardAccessApi.revokeAccess).mockResolvedValue() + + const readRequest = store.fetchBoardAccess('board-1') + await store.revokeAccess('board-1', 'viewer') + read.reject(new Error('stale failure')) + await expect(readRequest).rejects.toThrow('stale failure') + + expect(store.error).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) +}) From c43a5ace7f403f773153cf91a7d62e312aab1b9c Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:07:44 +0100 Subject: [PATCH 02/12] fix(permissions): bind access state to reads and sessions --- .../2026-09-21-permission-read-ownership.md | 41 +++++ .../src/store/permissionsStore.ts | 171 +++++++++++++++--- .../src/tests/store/permissionsStore.spec.ts | 6 +- .../store/permissionsStoreOwnership.spec.ts | 16 ++ 4 files changed, 203 insertions(+), 31 deletions(-) create mode 100644 docs/analysis/2026-09-21-permission-read-ownership.md diff --git a/docs/analysis/2026-09-21-permission-read-ownership.md b/docs/analysis/2026-09-21-permission-read-ownership.md new file mode 100644 index 000000000..1d65748d1 --- /dev/null +++ b/docs/analysis/2026-09-21-permission-read-ownership.md @@ -0,0 +1,41 @@ +# Board-access read and session ownership + +Status: draft PR #3330, 2026-09-21. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`. + +## Reproduced defects + +A board-access read could settle after a confirmed grant, update or revoke and +replace that newer client state. Same-board reads were last-response-wins, one +shared loading Boolean could clear while other boards still loaded, and pending +reads or mutations retained permission to publish after session replacement. + +A supplemental runner transpiled and executed the actual store module with only +Pinia/Vue/API/session boundaries stubbed. Seven original schedules failed on +`main`: revoke, update, reverse reads, independent loading, same-user session +replacement, old-session mutation and stale failure. The committed Vitest suite +also covers grant settlement. + +## Contract + +- One read owner exists per board; unrelated boards remain concurrent. +- A successful mutation advances that board's generation and retires older reads. +- Session identity/auth/demo transitions synchronously advance an epoch, clear + cached access, retire operations and reset loading/error. +- Success, failure, toast and cache writes require the initiating session epoch. +- Loading is derived from current operation tokens, not whichever call settles. +- A stale call still resolves or rejects to its caller; it loses only permission + to alter the replacement session's UI state. + +Server authorization remains authoritative. This corrects truthful client cache +behavior and does not claim a server-side authorization bypass. Same-board +mutation serialization is outside this slice. + +## Verification and remaining gates + +The actual-module supplemental suite changed from 0/7 ownership cases passing on +`main` to 7/7 after the correction; all twelve integration/permission schedules +pass together. TypeScript syntax transpilation passes. Existing permission-store +tests are adjusted so authenticated fixture state is established before the +session-watching store is created. Canonical lint, typecheck, build, complete +Vitest coverage, exact-head hosted CI and independent review remain required. +No merge, release or deployment qualification is claimed here. diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index 938eb7296..ff11fe38e 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { ref, computed } from 'vue' +import { ref, computed, watch } from 'vue' import { boardAccessApi } from '../api/boardAccessApi' import { useToastStore } from './toastStore' import { useSessionStore } from './sessionStore' @@ -16,6 +16,100 @@ export const usePermissionsStore = defineStore('permissions', () => { const loading = ref(false) const error = ref(null) + interface OperationOwner { + epoch: number + token: symbol + } + + interface ReadOwner extends OperationOwner { + observedMutationGeneration: number + } + + let sessionEpoch = 0 + const activeOperations = new Set() + const activeReadByBoard = new Map() + const mutationGenerationByBoard = new Map() + + function syncLoading() { + loading.value = activeOperations.size > 0 + } + + function beginOperation(label: string): OperationOwner { + const owner = { epoch: sessionEpoch, token: Symbol(label) } + activeOperations.add(owner.token) + error.value = null + syncLoading() + return owner + } + + function ownsSession(owner: OperationOwner): boolean { + return owner.epoch === sessionEpoch + } + + function finishOperation(owner: OperationOwner) { + if (!ownsSession(owner)) return + activeOperations.delete(owner.token) + syncLoading() + } + + function mutationGeneration(boardId: string): number { + return mutationGenerationByBoard.get(boardId) ?? 0 + } + + function beginRead(boardId: string): ReadOwner { + const previous = activeReadByBoard.get(boardId) + if (previous?.epoch === sessionEpoch) activeOperations.delete(previous.token) + + const operation = beginOperation(`read:${boardId}`) + const owner = { + ...operation, + observedMutationGeneration: mutationGeneration(boardId), + } + activeReadByBoard.set(boardId, owner) + return owner + } + + function ownsRead(boardId: string, owner: ReadOwner): boolean { + const current = activeReadByBoard.get(boardId) + return ownsSession(owner) + && current?.token === owner.token + && owner.observedMutationGeneration === mutationGeneration(boardId) + } + + function finishRead(boardId: string, owner: ReadOwner) { + if (activeReadByBoard.get(boardId)?.token === owner.token) { + activeReadByBoard.delete(boardId) + } + finishOperation(owner) + } + + function recordMutation(boardId: string) { + mutationGenerationByBoard.set(boardId, mutationGeneration(boardId) + 1) + + const staleRead = activeReadByBoard.get(boardId) + if (staleRead?.epoch === sessionEpoch) { + activeReadByBoard.delete(boardId) + activeOperations.delete(staleRead.token) + syncLoading() + } + } + + function resetForSession() { + sessionEpoch += 1 + activeOperations.clear() + activeReadByBoard.clear() + mutationGenerationByBoard.clear() + boardAccess.value = new Map() + loading.value = false + error.value = null + } + + watch( + () => [session.userId, session.isAuthenticated, session.isDemo], + resetForSession, + { flush: 'sync' }, + ) + function guardDemoMutation(): never | void { if (isDemoMode) { toast.info('This action is view-only in demo mode.') @@ -62,86 +156,103 @@ export const usePermissionsStore = defineStore('permissions', () => { loading.value = false return } + + const owner = beginRead(boardId) try { - loading.value = true - error.value = null const access = await boardAccessApi.getAccess(boardId) + if (!ownsRead(boardId, owner)) return boardAccess.value.set(boardId, access) } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch board access').message - error.value = msg - toast.error(msg) + if (ownsRead(boardId, owner)) { + const msg = getErrorDisplay(e, 'Failed to fetch board access').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishRead(boardId, owner) } } async function grantAccess(boardId: string, dto: GrantAccessDto) { guardDemoMutation() + const owner = beginOperation(`grant:${boardId}`) try { - loading.value = true - error.value = null session.requireUserId('board access management') const access = await boardAccessApi.grantAccess(boardId, dto) + if (!ownsSession(owner)) return access + + recordMutation(boardId) const existing = boardAccess.value.get(boardId) ?? [] boardAccess.value.set(boardId, [...existing, access]) toast.success('Access granted') return access } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to grant access').message - error.value = msg - toast.error(msg) + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to grant access').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } async function updateAccess(boardId: string, accessId: string, dto: UpdateAccessDto) { guardDemoMutation() + const owner = beginOperation(`update:${boardId}:${accessId}`) try { - loading.value = true - error.value = null session.requireUserId('board access management') const updated = await boardAccessApi.updateAccess(boardId, accessId, dto) + if (!ownsSession(owner)) return updated + + recordMutation(boardId) const existing = boardAccess.value.get(boardId) ?? [] - const index = existing.findIndex(a => a.id === accessId) - if (index !== -1) { - existing[index] = updated - boardAccess.value.set(boardId, [...existing]) + if (existing.some(access => access.id === accessId)) { + boardAccess.value.set( + boardId, + existing.map(access => access.id === accessId ? updated : access), + ) } toast.success('Access updated') return updated } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to update access').message - error.value = msg - toast.error(msg) + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to update access').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } async function revokeAccess(boardId: string, accessId: string) { guardDemoMutation() + const owner = beginOperation(`revoke:${boardId}:${accessId}`) try { - loading.value = true - error.value = null session.requireUserId('board access management') await boardAccessApi.revokeAccess(boardId, accessId) + if (!ownsSession(owner)) return + + recordMutation(boardId) const existing = boardAccess.value.get(boardId) ?? [] - boardAccess.value.set(boardId, existing.filter(a => a.id !== accessId)) + boardAccess.value.set(boardId, existing.filter(access => access.id !== accessId)) toast.success('Access revoked') } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to revoke access').message - error.value = msg - toast.error(msg) + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to revoke access').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } + return { boardAccess, loading, diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStore.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStore.spec.ts index d579138a3..33499b2d3 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStore.spec.ts @@ -40,8 +40,9 @@ describe('permissionsStore', () => { beforeEach(() => { setActivePinia(createPinia()) - store = usePermissionsStore() sessionStore = useSessionStore() + sessionStore.userId = 'user-1' + store = usePermissionsStore() vi.clearAllMocks() }) @@ -186,6 +187,8 @@ describe('permissionsStore', () => { describe('guardrails', () => { it('throws if grantAccess is called without a session user', async () => { + sessionStore.userId = null + await expect(store.grantAccess('board-1', { userId: 'user-2', role: 'Viewer' })) .rejects .toThrow('You must be logged in to use board access management.') @@ -193,6 +196,7 @@ describe('permissionsStore', () => { }) it('returns null role checks when no session user exists', () => { + sessionStore.userId = null store.boardAccess.set('board-1', [makeAccess({ userId: 'user-1', role: 'Owner' })]) expect(store.currentUserRole('board-1')).toBeNull() diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts index 46ec3e60f..aae330eaa 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts @@ -85,6 +85,22 @@ describe('permissionsStore async ownership', () => { expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner']) }) + it('does not let a read started before grant erase the granted entry', async () => { + const owner = access({ id: 'owner', userId: 'owner-1', role: 'Owner' }) + const granted = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) + store.boardAccess.set('board-1', [owner]) + const read = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(read.promise) + vi.mocked(boardAccessApi.grantAccess).mockResolvedValue(granted) + + const readRequest = store.fetchBoardAccess('board-1') + await store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + read.resolve([owner]) + await readRequest + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner', 'viewer']) + }) + it('does not let a read started before a role update restore the old role', async () => { const oldEntry = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) const updatedEntry = access({ id: 'viewer', userId: 'viewer-1', role: 'Admin' }) From 40cfd0b9fa485166ff55a5d1c7650a35195ebb85 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:25:31 +0100 Subject: [PATCH 03/12] test(permissions): reproduce same-entry mutation ordering --- .../permissionsStoreMutationOrder.spec.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts new file mode 100644 index 000000000..5cb3a10b6 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts @@ -0,0 +1,179 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { boardAccessApi } from '../../api/boardAccessApi' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useSessionStore } from '../../store/sessionStore' +import type { BoardAccess } from '../../types/access' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../api/boardAccessApi', () => ({ + boardAccessApi: { + getAccess: vi.fn(), + grantAccess: vi.fn(), + updateAccess: vi.fn(), + revokeAccess: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +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 access(overrides: Partial = {}): BoardAccess { + return { + id: 'access-1', + boardId: 'board-1', + userId: 'viewer-1', + role: 'Viewer', + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + ...overrides, + } +} + +async function flushQueue() { + await Promise.resolve() + await Promise.resolve() +} + +describe('permissionsStore mutation ordering', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'owner-1' + store = usePermissionsStore() + store.boardAccess.set('board-1', [access()]) + vi.clearAllMocks() + }) + + it('serializes two role updates for one access row in intent order', async () => { + const first = deferred() + const second = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const secondRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(1) + first.resolve(access({ role: 'Editor' })) + await firstRequest + await flushQueue() + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2) + second.resolve(access({ role: 'Admin' })) + await secondRequest + + expect(store.boardAccess.get('board-1')?.[0].role).toBe('Admin') + expect(store.error).toBeNull() + }) + + it('continues with the queued update after its predecessor fails', async () => { + const first = deferred() + const second = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const secondRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + + first.reject(new Error('first failed')) + await expect(firstRequest).rejects.toThrow('first failed') + await flushQueue() + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2) + + second.resolve(access({ role: 'Admin' })) + await secondRequest + expect(store.boardAccess.get('board-1')?.[0].role).toBe('Admin') + expect(store.error).toBeNull() + }) + + it('orders update before revoke and leaves the row removed', async () => { + const update = deferred() + const revoke = deferred() + vi.mocked(boardAccessApi.updateAccess).mockReturnValue(update.promise) + vi.mocked(boardAccessApi.revokeAccess).mockReturnValue(revoke.promise) + + const updateRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + const revokeRequest = store.revokeAccess('board-1', 'access-1') + expect(boardAccessApi.revokeAccess).not.toHaveBeenCalled() + + update.resolve(access({ role: 'Admin' })) + await updateRequest + await flushQueue() + expect(boardAccessApi.revokeAccess).toHaveBeenCalledTimes(1) + + revoke.resolve() + await revokeRequest + expect(store.boardAccess.get('board-1')).toEqual([]) + }) + + it('does not start queued old-session transport after logout', async () => { + const first = deferred() + vi.mocked(boardAccessApi.updateAccess).mockReturnValue(first.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const queuedRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + session.userId = null + session.userId = 'owner-1' + + first.resolve(access({ role: 'Editor' })) + await firstRequest + await queuedRequest + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(1) + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + }) + + it('keeps different access rows concurrent', async () => { + store.boardAccess.set('board-1', [ + access({ id: 'access-1', userId: 'viewer-1' }), + access({ id: 'access-2', userId: 'viewer-2' }), + ]) + const first = deferred() + const second = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const secondRequest = store.updateAccess('board-1', 'access-2', { role: 'Admin' }) + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2) + first.resolve(access({ id: 'access-1', userId: 'viewer-1', role: 'Editor' })) + second.resolve(access({ id: 'access-2', userId: 'viewer-2', role: 'Admin' })) + await Promise.all([firstRequest, secondRequest]) + + expect(store.boardAccess.get('board-1')?.map(item => item.role)).toEqual(['Editor', 'Admin']) + }) +}) From c1fcc1e4dce6aa0147c3021774676c583c31e0e6 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:26:55 +0100 Subject: [PATCH 04/12] fix(permissions): serialize same-entry mutations --- .../2026-09-21-permission-mutation-order.md | 46 +++++++ .../src/store/permissionsStore.ts | 130 +++++++++++------- 2 files changed, 130 insertions(+), 46 deletions(-) create mode 100644 docs/analysis/2026-09-21-permission-mutation-order.md diff --git a/docs/analysis/2026-09-21-permission-mutation-order.md b/docs/analysis/2026-09-21-permission-mutation-order.md new file mode 100644 index 000000000..aa8f03a14 --- /dev/null +++ b/docs/analysis/2026-09-21-permission-mutation-order.md @@ -0,0 +1,46 @@ +# Board-access mutation ordering + +Status: stacked draft PR #3335, 2026-09-21. Parent: PR #3330 at +`c43a5ace7f403f773153cf91a7d62e312aab1b9c`. + +## Reproduced defect + +`updateAccess` and `revokeAccess` started independently for one access row even +though the API accepts no expected revision and the entity has no configured +concurrency token. Two role changes could therefore commit or settle in an order +that differed from the user's clicks. Update/revoke could also overlap, and a +queued pre-logout intent had no transport-time session check. + +A supplemental runner executes the actual production store with only +Pinia/Vue/API/session boundaries stubbed. Against the parent, four ordering and +session schedules fail while the different-access concurrency control passes. +The same five schedules pass after the correction. + +## Contract + +- One queue exists per `{boardId, accessId}`. Update and revoke for that row run + in submission order; different rows remain concurrent. +- The first intent starts transport synchronously. A later intent waits for its + predecessor to finish, regardless of success or failure. +- Each queued operation owns a loading token from submission through settlement, + so loading does not drop between same-entry operations. +- Immediately before transport, queued work rechecks the initiating session + epoch. Session replacement clears queue registration and prevents old intent + from using later credentials. +- A predecessor failure does not cancel the next intent. The next transport + clears the predecessor's shared error before running. +- Existing successful-mutation read invalidation and stale settlement rules from + #3330 remain unchanged. + +The client queue preserves one client's submission order only. It does not solve +cross-device concurrency; the backend currently exposes no revision precondition. + +## Verification and remaining gates + +Actual-module red/green: 1/5 schedules passed on the parent, 5/5 after correction. +The preceding twelve read/session ownership schedules also remain 12/12 green. +Changed TypeScript source/tests transpile without diagnostics. Canonical Pinia/ +Vitest, lint, project typecheck, build, full hosted CI and independent review are +still required. Because this is stacked work, retarget to current `main` and +requalify after #3330 lands. No merge, release or deployment qualification is +claimed. diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index ff11fe38e..e0fd36bcd 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -29,6 +29,7 @@ export const usePermissionsStore = defineStore('permissions', () => { const activeOperations = new Set() const activeReadByBoard = new Map() const mutationGenerationByBoard = new Map() + const mutationTails = new Map>() function syncLoading() { loading.value = activeOperations.size > 0 @@ -94,11 +95,40 @@ export const usePermissionsStore = defineStore('permissions', () => { } } + async function enqueueAccessMutation( + boardId: string, + accessId: string, + label: string, + task: (owner: OperationOwner) => Promise, + ): Promise { + const key = `${boardId}:${accessId}` + const predecessor = mutationTails.get(key) + const owner = beginOperation(label) + let release!: () => void + const tail = new Promise((resolve) => { release = resolve }) + mutationTails.set(key, tail) + + try { + if (predecessor) await predecessor + if (!ownsSession(owner)) return undefined + + // A predecessor may have failed after this intent was queued. Clear its + // shared error when this operation becomes the active transport owner. + error.value = null + return await task(owner) + } finally { + finishOperation(owner) + release() + if (mutationTails.get(key) === tail) mutationTails.delete(key) + } + } + function resetForSession() { sessionEpoch += 1 activeOperations.clear() activeReadByBoard.clear() mutationGenerationByBoard.clear() + mutationTails.clear() boardAccess.value = new Map() loading.value = false error.value = null @@ -201,56 +231,64 @@ export const usePermissionsStore = defineStore('permissions', () => { async function updateAccess(boardId: string, accessId: string, dto: UpdateAccessDto) { guardDemoMutation() - const owner = beginOperation(`update:${boardId}:${accessId}`) - try { - session.requireUserId('board access management') - const updated = await boardAccessApi.updateAccess(boardId, accessId, dto) - if (!ownsSession(owner)) return updated - - recordMutation(boardId) - const existing = boardAccess.value.get(boardId) ?? [] - if (existing.some(access => access.id === accessId)) { - boardAccess.value.set( - boardId, - existing.map(access => access.id === accessId ? updated : access), - ) - } - toast.success('Access updated') - return updated - } catch (e: unknown) { - if (ownsSession(owner)) { - const msg = getErrorDisplay(e, 'Failed to update access').message - error.value = msg - toast.error(msg) - } - throw e - } finally { - finishOperation(owner) - } + return await enqueueAccessMutation( + boardId, + accessId, + `update:${boardId}:${accessId}`, + async (owner) => { + try { + session.requireUserId('board access management') + const updated = await boardAccessApi.updateAccess(boardId, accessId, dto) + if (!ownsSession(owner)) return updated + + recordMutation(boardId) + const existing = boardAccess.value.get(boardId) ?? [] + if (existing.some(access => access.id === accessId)) { + boardAccess.value.set( + boardId, + existing.map(access => access.id === accessId ? updated : access), + ) + } + toast.success('Access updated') + return updated + } catch (e: unknown) { + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to update access').message + error.value = msg + toast.error(msg) + } + throw e + } + }, + ) } async function revokeAccess(boardId: string, accessId: string) { guardDemoMutation() - const owner = beginOperation(`revoke:${boardId}:${accessId}`) - try { - session.requireUserId('board access management') - await boardAccessApi.revokeAccess(boardId, accessId) - if (!ownsSession(owner)) return - - recordMutation(boardId) - const existing = boardAccess.value.get(boardId) ?? [] - boardAccess.value.set(boardId, existing.filter(access => access.id !== accessId)) - toast.success('Access revoked') - } catch (e: unknown) { - if (ownsSession(owner)) { - const msg = getErrorDisplay(e, 'Failed to revoke access').message - error.value = msg - toast.error(msg) - } - throw e - } finally { - finishOperation(owner) - } + await enqueueAccessMutation( + boardId, + accessId, + `revoke:${boardId}:${accessId}`, + async (owner) => { + try { + session.requireUserId('board access management') + await boardAccessApi.revokeAccess(boardId, accessId) + if (!ownsSession(owner)) return + + recordMutation(boardId) + const existing = boardAccess.value.get(boardId) ?? [] + boardAccess.value.set(boardId, existing.filter(access => access.id !== accessId)) + toast.success('Access revoked') + } catch (e: unknown) { + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to revoke access').message + error.value = msg + toast.error(msg) + } + throw e + } + }, + ) } return { From 216ec3a553c3f45ccc1f415761990f492d1d23c9 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:33:43 +0100 Subject: [PATCH 05/12] fix(permissions): deduplicate grant settlement by stable id --- frontend/taskdeck-web/src/store/permissionsStore.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index ff11fe38e..91501b978 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -184,7 +184,9 @@ export const usePermissionsStore = defineStore('permissions', () => { recordMutation(boardId) const existing = boardAccess.value.get(boardId) ?? [] - boardAccess.value.set(boardId, [...existing, access]) + if (!existing.some(entry => entry.id === access.id)) { + boardAccess.value.set(boardId, [...existing, access]) + } toast.success('Access granted') return access } catch (e: unknown) { From 71a20bc8ad9be150b5bebfa949983770eb60ce58 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:34:18 +0100 Subject: [PATCH 06/12] test(permissions): pin grant settlement deduplication --- .../store/permissionsStoreOwnership.spec.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts index aae330eaa..90e277250 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts @@ -101,6 +101,22 @@ describe('permissionsStore async ownership', () => { expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner', 'viewer']) }) + it('does not duplicate a grant already observed by a newer authoritative read', async () => { + const owner = access({ id: 'owner', userId: 'owner-1', role: 'Owner' }) + const granted = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) + store.boardAccess.set('board-1', [owner]) + const grant = deferred() + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(grant.promise) + vi.mocked(boardAccessApi.getAccess).mockResolvedValue([owner, granted]) + + const grantRequest = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + await store.fetchBoardAccess('board-1') + grant.resolve(granted) + await grantRequest + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner', 'viewer']) + }) + it('does not let a read started before a role update restore the old role', async () => { const oldEntry = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) const updatedEntry = access({ id: 'viewer', userId: 'viewer-1', role: 'Admin' }) From cfeb1bffdbd21354dd974c8c6c8e09e468826337 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:14:38 +0100 Subject: [PATCH 07/12] test(permissions): invalidate operations on token rotation --- .../permissionsStoreTokenOwnership.spec.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts new file mode 100644 index 000000000..b44a56bf4 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { boardAccessApi } from '../../api/boardAccessApi' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useSessionStore } from '../../store/sessionStore' +import type { BoardAccess } from '../../types/access' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../api/boardAccessApi', () => ({ + boardAccessApi: { + getAccess: vi.fn(), + grantAccess: vi.fn(), + updateAccess: vi.fn(), + revokeAccess: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + refreshToken: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +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 access(id: string): BoardAccess { + return { + id, + boardId: 'board-1', + userId: 'viewer-1', + role: 'Viewer', + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + } +} + +describe('permissionsStore token ownership', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'owner-1' + session.token = token('old') + store = usePermissionsStore() + vi.clearAllMocks() + }) + + it('invalidates an old read when the credential rotates for the same user', async () => { + const pending = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(pending.promise) + const request = store.fetchBoardAccess('board-1') + + session.token = token('new') + pending.resolve([access('old-token-read')]) + await request + + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('suppresses a stale mutation failure after credential rotation', async () => { + const pending = deferred() + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(pending.promise) + const request = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + + session.token = token('new') + pending.reject(new Error('old credential failure')) + await expect(request).rejects.toThrow('old credential failure') + + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) +}) From 01af1833e8a71604c45fd977759d3233bbbe9753 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:16:09 +0100 Subject: [PATCH 08/12] test(permissions): preserve independent mutation errors --- ...issionsStoreMutationErrorOwnership.spec.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts new file mode 100644 index 000000000..259430539 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { boardAccessApi } from '../../api/boardAccessApi' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useSessionStore } from '../../store/sessionStore' +import type { BoardAccess } from '../../types/access' + +vi.mock('../../api/boardAccessApi', () => ({ + boardAccessApi: { + getAccess: vi.fn(), + grantAccess: vi.fn(), + updateAccess: vi.fn(), + revokeAccess: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }), +})) + +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 access(id: string, role: BoardAccess['role'] = 'Viewer'): BoardAccess { + return { + id, + boardId: 'board-1', + userId: `${id}-user`, + role, + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + } +} + +async function flushQueue() { + await Promise.resolve() + await Promise.resolve() +} + +describe('permissionsStore mutation error ownership', () => { + beforeEach(() => { + setActivePinia(createPinia()) + const session = useSessionStore() + session.userId = 'owner-1' + vi.clearAllMocks() + }) + + it('does not erase an independent failure when queued same-entry work starts', async () => { + const store = usePermissionsStore() + store.boardAccess.set('board-1', [access('access-1'), access('access-2')]) + + const first = deferred() + const independent = deferred() + const queued = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(independent.promise) + .mockReturnValueOnce(queued.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const queuedRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + const independentRequest = store.updateAccess('board-1', 'access-2', { role: 'Admin' }) + + independent.reject(new Error('independent access failed')) + await expect(independentRequest).rejects.toThrow('independent access failed') + expect(store.error).toBe('independent access failed') + + first.resolve(access('access-1', 'Editor')) + await firstRequest + await flushQueue() + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(3) + expect(store.error).toBe('independent access failed') + + queued.resolve(access('access-1', 'Admin')) + await queuedRequest + expect(store.error).toBe('independent access failed') + }) +}) From 59debd4cbc0b32cc276e169e410207d06af29159 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:26:45 +0100 Subject: [PATCH 09/12] fix(permissions): invalidate ownership on token rotation --- frontend/taskdeck-web/src/store/permissionsStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index 91501b978..52932c444 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -105,7 +105,7 @@ export const usePermissionsStore = defineStore('permissions', () => { } watch( - () => [session.userId, session.isAuthenticated, session.isDemo], + () => [session.userId, session.token, session.isAuthenticated, session.isDemo], resetForSession, { flush: 'sync' }, ) From f3e0d7f0ddd4893995862d6d7a5184ba3ffccbf0 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Tue, 22 Sep 2026 21:15:39 +0100 Subject: [PATCH 10/12] Restore permission mutation ordering regressions --- ...issionsStoreMutationErrorOwnership.spec.ts | 99 ++++++++++ .../permissionsStoreMutationOrder.spec.ts | 179 ++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts create mode 100644 frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts new file mode 100644 index 000000000..259430539 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { boardAccessApi } from '../../api/boardAccessApi' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useSessionStore } from '../../store/sessionStore' +import type { BoardAccess } from '../../types/access' + +vi.mock('../../api/boardAccessApi', () => ({ + boardAccessApi: { + getAccess: vi.fn(), + grantAccess: vi.fn(), + updateAccess: vi.fn(), + revokeAccess: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }), +})) + +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 access(id: string, role: BoardAccess['role'] = 'Viewer'): BoardAccess { + return { + id, + boardId: 'board-1', + userId: `${id}-user`, + role, + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + } +} + +async function flushQueue() { + await Promise.resolve() + await Promise.resolve() +} + +describe('permissionsStore mutation error ownership', () => { + beforeEach(() => { + setActivePinia(createPinia()) + const session = useSessionStore() + session.userId = 'owner-1' + vi.clearAllMocks() + }) + + it('does not erase an independent failure when queued same-entry work starts', async () => { + const store = usePermissionsStore() + store.boardAccess.set('board-1', [access('access-1'), access('access-2')]) + + const first = deferred() + const independent = deferred() + const queued = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(independent.promise) + .mockReturnValueOnce(queued.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const queuedRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + const independentRequest = store.updateAccess('board-1', 'access-2', { role: 'Admin' }) + + independent.reject(new Error('independent access failed')) + await expect(independentRequest).rejects.toThrow('independent access failed') + expect(store.error).toBe('independent access failed') + + first.resolve(access('access-1', 'Editor')) + await firstRequest + await flushQueue() + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(3) + expect(store.error).toBe('independent access failed') + + queued.resolve(access('access-1', 'Admin')) + await queuedRequest + expect(store.error).toBe('independent access failed') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts new file mode 100644 index 000000000..5cb3a10b6 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts @@ -0,0 +1,179 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { boardAccessApi } from '../../api/boardAccessApi' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useSessionStore } from '../../store/sessionStore' +import type { BoardAccess } from '../../types/access' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../api/boardAccessApi', () => ({ + boardAccessApi: { + getAccess: vi.fn(), + grantAccess: vi.fn(), + updateAccess: vi.fn(), + revokeAccess: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +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 access(overrides: Partial = {}): BoardAccess { + return { + id: 'access-1', + boardId: 'board-1', + userId: 'viewer-1', + role: 'Viewer', + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + ...overrides, + } +} + +async function flushQueue() { + await Promise.resolve() + await Promise.resolve() +} + +describe('permissionsStore mutation ordering', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'owner-1' + store = usePermissionsStore() + store.boardAccess.set('board-1', [access()]) + vi.clearAllMocks() + }) + + it('serializes two role updates for one access row in intent order', async () => { + const first = deferred() + const second = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const secondRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(1) + first.resolve(access({ role: 'Editor' })) + await firstRequest + await flushQueue() + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2) + second.resolve(access({ role: 'Admin' })) + await secondRequest + + expect(store.boardAccess.get('board-1')?.[0].role).toBe('Admin') + expect(store.error).toBeNull() + }) + + it('continues with the queued update after its predecessor fails', async () => { + const first = deferred() + const second = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const secondRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + + first.reject(new Error('first failed')) + await expect(firstRequest).rejects.toThrow('first failed') + await flushQueue() + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2) + + second.resolve(access({ role: 'Admin' })) + await secondRequest + expect(store.boardAccess.get('board-1')?.[0].role).toBe('Admin') + expect(store.error).toBeNull() + }) + + it('orders update before revoke and leaves the row removed', async () => { + const update = deferred() + const revoke = deferred() + vi.mocked(boardAccessApi.updateAccess).mockReturnValue(update.promise) + vi.mocked(boardAccessApi.revokeAccess).mockReturnValue(revoke.promise) + + const updateRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + const revokeRequest = store.revokeAccess('board-1', 'access-1') + expect(boardAccessApi.revokeAccess).not.toHaveBeenCalled() + + update.resolve(access({ role: 'Admin' })) + await updateRequest + await flushQueue() + expect(boardAccessApi.revokeAccess).toHaveBeenCalledTimes(1) + + revoke.resolve() + await revokeRequest + expect(store.boardAccess.get('board-1')).toEqual([]) + }) + + it('does not start queued old-session transport after logout', async () => { + const first = deferred() + vi.mocked(boardAccessApi.updateAccess).mockReturnValue(first.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const queuedRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + session.userId = null + session.userId = 'owner-1' + + first.resolve(access({ role: 'Editor' })) + await firstRequest + await queuedRequest + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(1) + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + }) + + it('keeps different access rows concurrent', async () => { + store.boardAccess.set('board-1', [ + access({ id: 'access-1', userId: 'viewer-1' }), + access({ id: 'access-2', userId: 'viewer-2' }), + ]) + const first = deferred() + const second = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const secondRequest = store.updateAccess('board-1', 'access-2', { role: 'Admin' }) + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2) + first.resolve(access({ id: 'access-1', userId: 'viewer-1', role: 'Editor' })) + second.resolve(access({ id: 'access-2', userId: 'viewer-2', role: 'Admin' })) + await Promise.all([firstRequest, secondRequest]) + + expect(store.boardAccess.get('board-1')?.map(item => item.role)).toEqual(['Editor', 'Admin']) + }) +}) From a918d5abfc43ae1da462eea4421682e35f4030ca Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Tue, 22 Sep 2026 21:53:58 +0100 Subject: [PATCH 11/12] Document ordered board-access mutations --- docs/STATUS.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 5e2d827f2..2ad7d9b7a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -14,8 +14,23 @@ Mutation transport is never replayed. This avoids manual permission refresh and presentation while retaining server authorization and the existing review-first board flow. Real-Pinia store tests cover session, token, read and mutation races; the source evidence note -is [board-access ownership](analysis/2026-09-21-permission-read-ownership.md). Same-entry mutation -ordering remains in #3335; human decisions in OUTSTANDING_TASKS.md remain open. +is [board-access ownership](analysis/2026-09-21-permission-read-ownership.md). Human decisions in +OUTSTANDING_TASKS.md remain open. + +## Board-access mutations preserve same-row intent order (#3333) + +Updates and revokes for one board-access entry now run in submission order. A queued operation +waits through a predecessor failure, holds its loading ownership while waiting, and checks the +initiating session again before transport. Other entries remain concurrent. A queued update after +a successful revoke cannot restore the removed row from an older response; a failed update reports +its own error. Starting a queued operation does not erase an unrelated entry's error receipt. + +This protects access changes during rapid board maintenance while keeping server authorization +and review-first board actions intact. Ordering is local to one client; the API has no revision +precondition for cross-device conflicts. Deferred Pinia tests cover the mutation schedules and +token rotation; [the evidence note](analysis/2026-09-21-permission-mutation-order.md) records +the contract and qualification limits. + ## Column writes follow their board visit (#3314) Create, update, delete and reorder share one mutation lane per board, preserving intent order From e99bba80208867869ff7909d0b175d5a4b36e945 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Tue, 22 Sep 2026 22:01:46 +0100 Subject: [PATCH 12/12] Keep permission mutation order across session replacement --- docs/STATUS.md | 3 +- .../2026-09-21-permission-mutation-order.md | 4 +-- .../src/store/permissionsStore.ts | 3 +- .../permissionsStoreTokenOwnership.spec.ts | 31 +++++++++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 2ad7d9b7a..71423b14a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -21,7 +21,8 @@ OUTSTANDING_TASKS.md remain open. Updates and revokes for one board-access entry now run in submission order. A queued operation waits through a predecessor failure, holds its loading ownership while waiting, and checks the -initiating session again before transport. Other entries remain concurrent. A queued update after +initiating session again before transport. A new session's same-entry intent also waits for an +older in-flight write to settle. Other entries remain concurrent. A queued update after a successful revoke cannot restore the removed row from an older response; a failed update reports its own error. Starting a queued operation does not erase an unrelated entry's error receipt. diff --git a/docs/analysis/2026-09-21-permission-mutation-order.md b/docs/analysis/2026-09-21-permission-mutation-order.md index c2286d99b..94d08363f 100644 --- a/docs/analysis/2026-09-21-permission-mutation-order.md +++ b/docs/analysis/2026-09-21-permission-mutation-order.md @@ -13,7 +13,7 @@ Independent mutation failures exposed a second ownership gap during review. A qu - One queue exists per `{boardId, accessId}`. Update and revoke for that row run in submission order; different rows remain concurrent. - The first intent starts transport synchronously. A later intent waits for its predecessor to finish, regardless of success or failure. - Each queued operation owns a loading token from submission through settlement, so loading does not drop between same-entry operations. -- Immediately before transport, queued work rechecks the initiating session epoch. Identity, token, authentication or demo replacement clears queue registration and prevents old intent from using later credentials. +- Immediately before transport, queued work rechecks the initiating session epoch. Identity, token, authentication or demo replacement retires old queued intent without using later credentials. In-flight write tails remain registered until settlement so a new session's same-entry intent cannot overtake an older server write. - A predecessor failure does not cancel the next intent. The queued transport clears an error only when that receipt is owned by its own predecessor; an unrelated concurrent failure remains visible. - Existing successful-mutation read invalidation, stable-ID grant deduplication and stale settlement rules from #3330 remain unchanged. @@ -25,4 +25,4 @@ The initial ordering negative control ran the actual production store with only Codex review identified the independent-error case. A dedicated deferred Pinia regression was committed before the correction: access row 2 fails while row 1's second update waits, then row 1's queued transport starts without erasing row 2's error. -The integration is rebuilt on the parent's token-rotation correction. A revoke-then-update regression also proves the second transport waits and a failed update does not resurrect the revoked row. Local focused permission tests pass (45 before the final added case, 46 after it); the full frontend suite passes 520 files, 7,387 tests with three existing skips. Typecheck, build, lint, docs governance and doc links pass; lint reports 11 existing warnings. The source commits, child-only diff, exact-head hosted CI and independent review must be verified before merge. No release or deployment qualification is claimed. +The integration is rebuilt on the parent's token-rotation correction. A revoke-then-update regression proves the second transport waits and a failed update does not resurrect the revoked row. Independent review found that clearing lane tails on token rotation let a new intent race an old in-flight write. A deferred regression failed on that reviewed head and passes after retaining the tail. The final focused permission run passes 47 tests in five files. The earlier full frontend run passed 520 files, 7,387 tests with three existing skips; final-head hosted CI remains required. Typecheck and changed-file lint pass at the fix, while full build/lint passed before it with 11 existing warnings. Docs governance and doc links pass after the fix. The source commits and child-only diff are verified, while exact-head hosted CI and fix review remain required before merge. A never-settling transport can hold its lane indefinitely; that lower-severity risk is tracked in #3364. No release or deployment qualification is claimed. diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index ad27a6916..b7f80b1bc 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -137,7 +137,8 @@ export const usePermissionsStore = defineStore('permissions', () => { activeReadByBoard.clear() readRetryByBoard.clear() mutationGenerationByBoard.clear() - mutationTails.clear() + // In-flight writes may still commit after token or account replacement. + // Keep their tails so a new same-entry intent cannot overtake them. loading.value = false clearError() } diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts index 3c0936a91..53781913e 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts @@ -125,6 +125,37 @@ describe('permissionsStore token ownership', () => { expect(store.loading).toBe(false) }) + it('waits for an old-token update before starting a new same-entry update', async () => { + const row = access('access-1') + const oldResult = { ...row, role: 'Editor' as const } + const newResult = { ...row, role: 'Admin' as const } + store.boardAccess.set('board-1', [row]) + const oldWrite = deferred() + const oldReconciliation = deferred() + const newWrite = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(oldWrite.promise) + .mockReturnValueOnce(newWrite.promise) + vi.mocked(boardAccessApi.getAccess).mockReturnValueOnce(oldReconciliation.promise) + + const first = store.updateAccess('board-1', row.id, { role: 'Editor' }) + session.token = token('replacement') + const second = store.updateAccess('board-1', row.id, { role: 'Admin' }) + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(1) + + oldWrite.resolve(oldResult) + await vi.waitFor(() => expect(boardAccessApi.getAccess).toHaveBeenCalledWith('board-1')) + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(1) + oldReconciliation.resolve([oldResult]) + await first + await vi.waitFor(() => expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2)) + + newWrite.resolve(newResult) + await second + expect(store.boardAccess.get('board-1')).toEqual([newResult]) + expect(store.loading).toBe(false) + }) + it('preserves loaded access while invalidating an old read after same-user token rotation', async () => { store.boardAccess.set('board-1', [access('existing')]) const pending = deferred()