From 5fc20fab99af434211e54d3e50f4ce019b825854 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:18:37 +0100 Subject: [PATCH 01/11] test(audit): reproduce stale history ownership races --- .../tests/store/auditStoreOwnership.spec.ts | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts new file mode 100644 index 000000000..3e648df6c --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { auditApi } from '../../api/auditApi' +import { useAuditStore } from '../../store/auditStore' +import { useSessionStore } from '../../store/sessionStore' +import type { AuditEntry } from '../../types/audit' + +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/auditApi', () => ({ + auditApi: { + getBoardHistory: vi.fn(), + getEntityHistory: vi.fn(), + getUserHistory: 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, +})) + +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 entry(id: string): AuditEntry { + return { + id, + entityType: 'Card', + entityId: id, + action: 'Updated', + userId: 'user-a', + userName: 'Alex', + changes: null, + timestamp: '2026-09-21T00:00:00Z', + } +} + +describe('auditStore async ownership', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'user-a' + session.token = 'token-a' + store = useAuditStore() + vi.clearAllMocks() + }) + + it('keeps the newest cross-kind history query when responses settle in reverse order', async () => { + const oldBoard = deferred() + const newEntity = deferred() + vi.mocked(auditApi.getBoardHistory).mockReturnValue(oldBoard.promise) + vi.mocked(auditApi.getEntityHistory).mockReturnValue(newEntity.promise) + + const oldRequest = store.fetchBoardHistory('board-old') + const newRequest = store.fetchEntityHistory('Card', 'card-new') + + newEntity.resolve([entry('new-entity')]) + await newRequest + oldBoard.resolve([entry('old-board')]) + await oldRequest + + expect(store.entries.map(item => item.id)).toEqual(['new-entity']) + expect(store.error).toBeNull() + }) + + it('suppresses stale failure UI after a newer query succeeds while preserving rejection', async () => { + const oldBoard = deferred() + const newUser = deferred() + vi.mocked(auditApi.getBoardHistory).mockReturnValue(oldBoard.promise) + vi.mocked(auditApi.getUserHistory).mockReturnValue(newUser.promise) + + const oldRequest = store.fetchBoardHistory('board-old') + const newRequest = store.fetchUserHistory() + + newUser.resolve([entry('new-user')]) + await newRequest + oldBoard.reject(new Error('stale board failure')) + await expect(oldRequest).rejects.toThrow('stale board failure') + + expect(store.entries.map(item => item.id)).toEqual(['new-user']) + expect(store.error).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('does not let an older finally clear loading owned by the current query', async () => { + const older = deferred() + const newer = deferred() + vi.mocked(auditApi.getBoardHistory).mockReturnValue(older.promise) + vi.mocked(auditApi.getUserHistory).mockReturnValue(newer.promise) + + const oldRequest = store.fetchBoardHistory('board-old') + const newRequest = store.fetchUserHistory() + + older.resolve([entry('old')]) + await oldRequest + expect(store.loading).toBe(true) + + newer.resolve([entry('new')]) + await newRequest + expect(store.loading).toBe(false) + }) + + it('clears the shared surface synchronously and ignores late success after token rotation', async () => { + store.entries = [entry('existing')] + store.error = 'existing error' + const pending = deferred() + vi.mocked(auditApi.getBoardHistory).mockReturnValue(pending.promise) + + const request = store.fetchBoardHistory('board-old') + expect(store.loading).toBe(true) + + session.token = 'token-b' + + expect(store.entries).toEqual([]) + expect(store.error).toBeNull() + expect(store.loading).toBe(false) + + pending.resolve([entry('old-token')]) + await request + + expect(store.entries).toEqual([]) + expect(store.error).toBeNull() + expect(store.loading).toBe(false) + }) + + it('suppresses stale failure UI after same-user token rotation while preserving rejection', async () => { + const pending = deferred() + vi.mocked(auditApi.getEntityHistory).mockReturnValue(pending.promise) + + const request = store.fetchEntityHistory('Card', 'card-old') + session.token = 'token-b' + pending.reject(new Error('old-token failure')) + await expect(request).rejects.toThrow('old-token failure') + + expect(store.entries).toEqual([]) + expect(store.error).toBeNull() + expect(store.loading).toBe(false) + expect(toastMocks.error).not.toHaveBeenCalled() + }) +}) From d727e05c85206200cdd7c39193fda5706c6922a9 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:31:17 +0100 Subject: [PATCH 02/11] fix(audit): bind history to query and credential lifetimes --- .../2026-09-21-audit-history-ownership.md | 45 ++++++ frontend/taskdeck-web/src/store/auditStore.ts | 130 +++++++++++------- 2 files changed, 127 insertions(+), 48 deletions(-) create mode 100644 docs/analysis/2026-09-21-audit-history-ownership.md diff --git a/docs/analysis/2026-09-21-audit-history-ownership.md b/docs/analysis/2026-09-21-audit-history-ownership.md new file mode 100644 index 000000000..484fe4123 --- /dev/null +++ b/docs/analysis/2026-09-21-audit-history-ownership.md @@ -0,0 +1,45 @@ +# Activity history request ownership + +Status: corrective draft for #3344 / PR #3345, based on `main` +`307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`. + +## Reproduced defect + +Board, entity, and user history requests all replace one `auditStore.entries` +surface. The previous implementation allowed every response, failure, toast, and +`finally` to commit. Ordinary Activity route changes could therefore restore an +older query, report an obsolete failure, or clear loading while the current query +was still pending. The store was also absent from AppShell's logout resets, so a +request started with retired credentials could repopulate audit data later. + +## Contract + +- The shared result surface has one current request owner across all query kinds. +- A newer query retires the previous owner's permission to commit UI state. +- Identity, token, authentication, or demo-session replacement synchronously + advances the credential epoch and clears the audit surface. +- A stale request still resolves or rejects to its original caller, but cannot + write entries, errors, toasts, loading, or final state. +- A current failure retains the previous result list and preserves the existing + public error/toast/rejection behavior. +- Limit clamping, endpoints, route behavior, demo behavior, and the public store + API remain unchanged. + +The three duplicated request bodies now use one private request helper so their +ownership and failure rules cannot drift independently. This is client-state +integrity, not transport cancellation or a server authorization claim. + +## Evidence and remaining gates + +The committed real Pinia/Vitest suite covers five deferred schedules. A bounded +supplemental runner transpiles and executes the actual production store with only +framework/API/session boundaries stubbed: + +- unchanged `main`: 0/5 passed; +- corrected source: 5/5 passed. + +The supplemental runner is not committed and does not replace canonical frontend +qualification. Before review-ready status, inspect the test-only hosted RED +artifact, then require exact-head lint, typecheck, production build, full Vitest +on Ubuntu and Windows, the complete Required CI/Extended/Self-Test workflows, and +fresh-context review. No merge, release, or deployment qualification is claimed. diff --git a/frontend/taskdeck-web/src/store/auditStore.ts b/frontend/taskdeck-web/src/store/auditStore.ts index bc1b3ac8c..286273cfb 100644 --- a/frontend/taskdeck-web/src/store/auditStore.ts +++ b/frontend/taskdeck-web/src/store/auditStore.ts @@ -1,89 +1,123 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, watch } from 'vue' import { auditApi } from '../api/auditApi' import { useToastStore } from './toastStore' +import { useSessionStore } from './sessionStore' import { isDemoMode } from '../utils/demoMode' import type { AuditEntry } from '../types/audit' import { getErrorDisplay } from '../composables/useErrorMapper' export const useAuditStore = defineStore('audit', () => { const toast = useToastStore() + const session = useSessionStore() const entries = ref([]) const loading = ref(false) const error = ref(null) + interface ReadOwner { + epoch: number + token: symbol + } + + let credentialEpoch = 0 + let currentRead: ReadOwner | null = null + function clampLimit(limit: number): number { if (limit < 1) return 1 if (limit > 100) return 100 return limit } - async function fetchBoardHistory(boardId: string, limit = 50) { - if (isDemoMode) { - loading.value = true - error.value = null - entries.value = [] - loading.value = false - return - } + function beginRead(): ReadOwner { + const owner = { epoch: credentialEpoch, token: Symbol('audit-history') } + currentRead = owner + error.value = null + loading.value = true + return owner + } + + function ownsRead(owner: ReadOwner): boolean { + return owner.epoch === credentialEpoch && currentRead?.token === owner.token + } + + function finishRead(owner: ReadOwner): void { + if (!ownsRead(owner)) return + currentRead = null + loading.value = false + } + + function resetForCredential(): void { + credentialEpoch += 1 + currentRead = null + entries.value = [] + loading.value = false + error.value = null + } + + watch( + () => [session.userId, session.token, session.isAuthenticated, session.isDemo], + resetForCredential, + { flush: 'sync' }, + ) + + async function fetchHistory( + request: () => Promise, + fallbackMessage: string, + ): Promise { + const owner = beginRead() try { - loading.value = true - error.value = null - entries.value = await auditApi.getBoardHistory(boardId, clampLimit(limit)) + const result = await request() + if (!ownsRead(owner)) return + entries.value = result } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch board history').message - error.value = msg - toast.error(msg) + if (ownsRead(owner)) { + const msg = getErrorDisplay(e, fallbackMessage).message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishRead(owner) } } - async function fetchEntityHistory(entityType: string, entityId: string, limit = 50) { + async function fetchBoardHistory(boardId: string, limit = 50) { if (isDemoMode) { - loading.value = true - error.value = null - entries.value = [] - loading.value = false + resetForCredential() return } - try { - loading.value = true - error.value = null - entries.value = await auditApi.getEntityHistory(entityType, entityId, clampLimit(limit)) - } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch entity history').message - error.value = msg - toast.error(msg) - throw e - } finally { - loading.value = false + + await fetchHistory( + () => auditApi.getBoardHistory(boardId, clampLimit(limit)), + 'Failed to fetch board history', + ) + } + + async function fetchEntityHistory(entityType: string, entityId: string, limit = 50) { + if (isDemoMode) { + resetForCredential() + return } + + await fetchHistory( + () => auditApi.getEntityHistory(entityType, entityId, clampLimit(limit)), + 'Failed to fetch entity history', + ) } async function fetchUserHistory(limit = 50) { if (isDemoMode) { - loading.value = true - error.value = null - entries.value = [] - loading.value = false + resetForCredential() return } - try { - loading.value = true - error.value = null - entries.value = await auditApi.getUserHistory(clampLimit(limit)) - } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch user history').message - error.value = msg - toast.error(msg) - throw e - } finally { - loading.value = false - } + + await fetchHistory( + () => auditApi.getUserHistory(clampLimit(limit)), + 'Failed to fetch user history', + ) } + return { entries, loading, From 555d7f05c222d6f3b85ebe8d3c243747d1f04e7c Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:48:18 +0100 Subject: [PATCH 03/11] test(audit): preserve loaded history across token refresh --- .../tests/store/auditStoreOwnership.spec.ts | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts index 3e648df6c..1904dc865 100644 --- a/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts @@ -131,7 +131,7 @@ describe('auditStore async ownership', () => { expect(store.loading).toBe(false) }) - it('clears the shared surface synchronously and ignores late success after token rotation', async () => { + it('preserves loaded history while invalidating an old-token success on refresh', async () => { store.entries = [entry('existing')] store.error = 'existing error' const pending = deferred() @@ -142,19 +142,20 @@ describe('auditStore async ownership', () => { session.token = 'token-b' - expect(store.entries).toEqual([]) + expect(store.entries.map(item => item.id)).toEqual(['existing']) expect(store.error).toBeNull() expect(store.loading).toBe(false) pending.resolve([entry('old-token')]) await request - expect(store.entries).toEqual([]) + expect(store.entries.map(item => item.id)).toEqual(['existing']) expect(store.error).toBeNull() expect(store.loading).toBe(false) }) - it('suppresses stale failure UI after same-user token rotation while preserving rejection', async () => { + it('preserves loaded history while suppressing an old-token failure', async () => { + store.entries = [entry('existing')] const pending = deferred() vi.mocked(auditApi.getEntityHistory).mockReturnValue(pending.promise) @@ -163,9 +164,29 @@ describe('auditStore async ownership', () => { pending.reject(new Error('old-token failure')) await expect(request).rejects.toThrow('old-token failure') - expect(store.entries).toEqual([]) + expect(store.entries.map(item => item.id)).toEqual(['existing']) expect(store.error).toBeNull() expect(store.loading).toBe(false) expect(toastMocks.error).not.toHaveBeenCalled() }) + + it('clears history on identity replacement and suppresses late settlement', async () => { + store.entries = [entry('existing')] + const pending = deferred() + vi.mocked(auditApi.getUserHistory).mockReturnValue(pending.promise) + + const request = store.fetchUserHistory() + session.userId = 'user-b' + + expect(store.entries).toEqual([]) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + + pending.resolve([entry('old-user')]) + await request + + expect(store.entries).toEqual([]) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) }) From 3f5044d50378ce2d9cef6db35798f12186dd0249 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:16:14 +0100 Subject: [PATCH 04/11] fix(audit): preserve history across token refresh --- frontend/taskdeck-web/src/store/auditStore.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/frontend/taskdeck-web/src/store/auditStore.ts b/frontend/taskdeck-web/src/store/auditStore.ts index 286273cfb..e09167539 100644 --- a/frontend/taskdeck-web/src/store/auditStore.ts +++ b/frontend/taskdeck-web/src/store/auditStore.ts @@ -47,17 +47,27 @@ export const useAuditStore = defineStore('audit', () => { loading.value = false } - function resetForCredential(): void { + function invalidateCurrentRead(): void { credentialEpoch += 1 currentRead = null - entries.value = [] loading.value = false error.value = null } + function resetForSession(): void { + invalidateCurrentRead() + entries.value = [] + } + + watch( + () => [session.userId, session.isAuthenticated, session.isDemo], + resetForSession, + { flush: 'sync' }, + ) + watch( - () => [session.userId, session.token, session.isAuthenticated, session.isDemo], - resetForCredential, + () => session.token, + invalidateCurrentRead, { flush: 'sync' }, ) @@ -84,7 +94,7 @@ export const useAuditStore = defineStore('audit', () => { async function fetchBoardHistory(boardId: string, limit = 50) { if (isDemoMode) { - resetForCredential() + resetForSession() return } @@ -96,7 +106,7 @@ export const useAuditStore = defineStore('audit', () => { async function fetchEntityHistory(entityType: string, entityId: string, limit = 50) { if (isDemoMode) { - resetForCredential() + resetForSession() return } @@ -108,7 +118,7 @@ export const useAuditStore = defineStore('audit', () => { async function fetchUserHistory(limit = 50) { if (isDemoMode) { - resetForCredential() + resetForSession() return } From 01604a50a9bb5a0e1319f72e7fde781572c74cb9 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:21:11 +0100 Subject: [PATCH 05/11] docs(audit): distinguish refresh invalidation from identity reset --- .../2026-09-21-audit-history-ownership.md | 63 ++++++++----------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/docs/analysis/2026-09-21-audit-history-ownership.md b/docs/analysis/2026-09-21-audit-history-ownership.md index 484fe4123..f55f5a397 100644 --- a/docs/analysis/2026-09-21-audit-history-ownership.md +++ b/docs/analysis/2026-09-21-audit-history-ownership.md @@ -1,45 +1,36 @@ # Activity history request ownership -Status: corrective draft for #3344 / PR #3345, based on `main` -`307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`. +Status: corrective draft for #3344 / PR #3345. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`. ## Reproduced defect -Board, entity, and user history requests all replace one `auditStore.entries` -surface. The previous implementation allowed every response, failure, toast, and -`finally` to commit. Ordinary Activity route changes could therefore restore an -older query, report an obsolete failure, or clear loading while the current query -was still pending. The store was also absent from AppShell's logout resets, so a -request started with retired credentials could repopulate audit data later. +Board, entity, and user history requests replace one `auditStore.entries` surface. The original store allowed every response, failure, toast, and `finally` to commit, so ordinary Activity route changes could restore an older query, report an obsolete failure, or clear loading while the current query was pending. + +The initial lifecycle correction then treated every token rotation as a full data reset. A successful same-user session extension therefore cleared the current Activity result even though the route and selected query were unchanged. ## Contract -- The shared result surface has one current request owner across all query kinds. +- One current request owner exists across board, entity, and user query kinds. - A newer query retires the previous owner's permission to commit UI state. -- Identity, token, authentication, or demo-session replacement synchronously - advances the credential epoch and clears the audit surface. -- A stale request still resolves or rejects to its original caller, but cannot - write entries, errors, toasts, loading, or final state. -- A current failure retains the previous result list and preserves the existing - public error/toast/rejection behavior. -- Limit clamping, endpoints, route behavior, demo behavior, and the public store - API remain unchanged. - -The three duplicated request bodies now use one private request helper so their -ownership and failure rules cannot drift independently. This is client-state -integrity, not transport cancellation or a server authorization claim. - -## Evidence and remaining gates - -The committed real Pinia/Vitest suite covers five deferred schedules. A bounded -supplemental runner transpiles and executes the actual production store with only -framework/API/session boundaries stubbed: - -- unchanged `main`: 0/5 passed; -- corrected source: 5/5 passed. - -The supplemental runner is not committed and does not replace canonical frontend -qualification. Before review-ready status, inspect the test-only hosted RED -artifact, then require exact-head lint, typecheck, production build, full Vitest -on Ubuntu and Windows, the complete Required CI/Extended/Self-Test workflows, and -fresh-context review. No merge, release, or deployment qualification is claimed. +- User identity, authentication, or demo-session replacement advances the epoch, retires work, and clears history. +- Token-only rotation advances the same request epoch and clears transient loading/error ownership, but preserves the already loaded history for the unchanged user and route. +- Stale work still resolves or rejects to its original caller, but cannot write entries, error, toast, loading, or final state. +- A current failure retains the previous result list and preserves the public error/toast/rejection behavior. +- Limit clamping, endpoints, route behavior, demo behavior, and the public store API remain unchanged. + +The three request bodies use one private helper so ownership and failure rules cannot drift. This is client-state integrity, not transport cancellation or a server authorization claim. + +## Test-first evidence + +The initial test-only head `3980e1e3e234a251cd89cad270b8d0ab86c3e5f2` produced five intended ownership failures against unchanged `main`. + +Review-regression head `93c20b679888394200354d80040e7f3c7dd5c353` ran the canonical Node 24 frontend suite on Ubuntu and Windows. Lint, typecheck, production build, and PWA validation passed on both platforms. Ubuntu JUnit recorded **7,156 tests, exactly 2 failures, 0 errors**; both failures were the new token-refresh preservation cases: + +1. preserve loaded history while suppressing an old-token success; +2. preserve loaded history while suppressing an old-token failure. + +No unrelated frontend test failed. + +## Remaining gates + +The production correction splits token-only invalidation from full identity reset. Exact-head lint, typecheck, build, complete Vitest on Ubuntu and Windows, full Required CI, Extended, Self-Test, and fresh-context review are still required. No merge, release, or deployment qualification is claimed. From 4a4d6b8be79c2536cbd519bccf2dba7bc319e303 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:29:48 +0100 Subject: [PATCH 06/11] test(audit): retry empty initial history after token refresh --- .../tests/store/auditStoreOwnership.spec.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts index 1904dc865..a55b1c8dd 100644 --- a/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts @@ -170,6 +170,34 @@ describe('auditStore async ownership', () => { expect(toastMocks.error).not.toHaveBeenCalled() }) + it('retries an empty initial history read after same-user token rotation', async () => { + const oldRead = deferred() + const freshRead = deferred() + vi.mocked(auditApi.getBoardHistory) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) + + const request = store.fetchBoardHistory('board-a') + session.token = 'token-b' + + expect(auditApi.getBoardHistory).toHaveBeenCalledTimes(2) + expect(store.entries).toEqual([]) + expect(store.loading).toBe(true) + + oldRead.resolve([entry('old-token')]) + await request + + expect(store.entries).toEqual([]) + expect(store.loading).toBe(true) + + freshRead.resolve([entry('fresh-token')]) + await vi.waitFor(() => { + expect(store.entries.map(item => item.id)).toEqual(['fresh-token']) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + }) + it('clears history on identity replacement and suppresses late settlement', async () => { store.entries = [entry('existing')] const pending = deferred() From 1494d1c822d896a3e7e8eb5f2477d2c9d753b918 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:41:56 +0100 Subject: [PATCH 07/11] fix(audit): retry empty active history after token refresh --- frontend/taskdeck-web/src/store/auditStore.ts | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/frontend/taskdeck-web/src/store/auditStore.ts b/frontend/taskdeck-web/src/store/auditStore.ts index e09167539..2f63245b9 100644 --- a/frontend/taskdeck-web/src/store/auditStore.ts +++ b/frontend/taskdeck-web/src/store/auditStore.ts @@ -15,6 +15,8 @@ export const useAuditStore = defineStore('audit', () => { const loading = ref(false) const error = ref(null) + type ReadRetry = () => Promise + interface ReadOwner { epoch: number token: symbol @@ -22,6 +24,7 @@ export const useAuditStore = defineStore('audit', () => { let credentialEpoch = 0 let currentRead: ReadOwner | null = null + let currentRetry: ReadRetry | null = null function clampLimit(limit: number): number { if (limit < 1) return 1 @@ -29,9 +32,10 @@ export const useAuditStore = defineStore('audit', () => { return limit } - function beginRead(): ReadOwner { + function beginRead(retry: ReadRetry): ReadOwner { const owner = { epoch: credentialEpoch, token: Symbol('audit-history') } currentRead = owner + currentRetry = retry error.value = null loading.value = true return owner @@ -44,16 +48,28 @@ export const useAuditStore = defineStore('audit', () => { function finishRead(owner: ReadOwner): void { if (!ownsRead(owner)) return currentRead = null + currentRetry = null loading.value = false } function invalidateCurrentRead(): void { credentialEpoch += 1 currentRead = null + currentRetry = null loading.value = false error.value = null } + function retryEmptyActiveRead(): void { + const retry = currentRead && entries.value.length === 0 ? currentRetry : null + invalidateCurrentRead() + if (retry) { + void retry().catch(() => { + // The retried store action owns current error/toast state. + }) + } + } + function resetForSession(): void { invalidateCurrentRead() entries.value = [] @@ -67,15 +83,16 @@ export const useAuditStore = defineStore('audit', () => { watch( () => session.token, - invalidateCurrentRead, + retryEmptyActiveRead, { flush: 'sync' }, ) async function fetchHistory( request: () => Promise, fallbackMessage: string, + retry: ReadRetry, ): Promise { - const owner = beginRead() + const owner = beginRead(retry) try { const result = await request() if (!ownsRead(owner)) return @@ -101,6 +118,7 @@ export const useAuditStore = defineStore('audit', () => { await fetchHistory( () => auditApi.getBoardHistory(boardId, clampLimit(limit)), 'Failed to fetch board history', + () => fetchBoardHistory(boardId, limit), ) } @@ -113,6 +131,7 @@ export const useAuditStore = defineStore('audit', () => { await fetchHistory( () => auditApi.getEntityHistory(entityType, entityId, clampLimit(limit)), 'Failed to fetch entity history', + () => fetchEntityHistory(entityType, entityId, limit), ) } @@ -125,6 +144,7 @@ export const useAuditStore = defineStore('audit', () => { await fetchHistory( () => auditApi.getUserHistory(clampLimit(limit)), 'Failed to fetch user history', + () => fetchUserHistory(limit), ) } From 9a32510ae92688a201d774746f365418809c5c86 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:49:32 +0100 Subject: [PATCH 08/11] docs(audit): record empty-read retry contract --- .../2026-09-21-audit-history-ownership.md | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/analysis/2026-09-21-audit-history-ownership.md b/docs/analysis/2026-09-21-audit-history-ownership.md index f55f5a397..757897bfd 100644 --- a/docs/analysis/2026-09-21-audit-history-ownership.md +++ b/docs/analysis/2026-09-21-audit-history-ownership.md @@ -2,35 +2,42 @@ Status: corrective draft for #3344 / PR #3345. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`. -## Reproduced defect +## Reproduced defects -Board, entity, and user history requests replace one `auditStore.entries` surface. The original store allowed every response, failure, toast, and `finally` to commit, so ordinary Activity route changes could restore an older query, report an obsolete failure, or clear loading while the current query was pending. +Board, entity and user history requests replace one `auditStore.entries` surface. The original store allowed every response, failure, toast and `finally` to commit, so route changes could restore older queries, report obsolete failures or clear current loading. -The initial lifecycle correction then treated every token rotation as a full data reset. A successful same-user session extension therefore cleared the current Activity result even though the route and selected query were unchanged. +The first lifecycle correction treated token refresh as full identity reset and cleared loaded history. The preservation correction then exposed a second boundary: an empty initial history read was retired during refresh and the unchanged route did not refetch. ## Contract -- One current request owner exists across board, entity, and user query kinds. +- One current request owner exists across board, entity and user query kinds. - A newer query retires the previous owner's permission to commit UI state. -- User identity, authentication, or demo-session replacement advances the epoch, retires work, and clears history. -- Token-only rotation advances the same request epoch and clears transient loading/error ownership, but preserves the already loaded history for the unchanged user and route. -- Stale work still resolves or rejects to its original caller, but cannot write entries, error, toast, loading, or final state. +- User identity, authentication or demo-session replacement advances the epoch, retires work and clears history. +- Token-only rotation preserves settled history, suppresses old-token UI settlement and restarts the active query only while history is still empty. +- The retry retains the exact board/entity/user parameters and limit captured by the active query. +- Stale work still resolves or rejects to its original caller, but cannot write entries, error, toast, loading or final state. - A current failure retains the previous result list and preserves the public error/toast/rejection behavior. -- Limit clamping, endpoints, route behavior, demo behavior, and the public store API remain unchanged. +- Limit clamping, endpoints, route behavior, demo behavior and the public store API remain unchanged. The three request bodies use one private helper so ownership and failure rules cannot drift. This is client-state integrity, not transport cancellation or a server authorization claim. ## Test-first evidence -The initial test-only head `3980e1e3e234a251cd89cad270b8d0ab86c3e5f2` produced five intended ownership failures against unchanged `main`. +Initial test-only head `3980e1e3e234a251cd89cad270b8d0ab86c3e5f2` produced five intended ownership failures against unchanged `main`. -Review-regression head `93c20b679888394200354d80040e7f3c7dd5c353` ran the canonical Node 24 frontend suite on Ubuntu and Windows. Lint, typecheck, production build, and PWA validation passed on both platforms. Ubuntu JUnit recorded **7,156 tests, exactly 2 failures, 0 errors**; both failures were the new token-refresh preservation cases: +Review-regression head `93c20b679888394200354d80040e7f3c7dd5c353` ran the canonical Node 24 frontend suite on Ubuntu and Windows. Lint, typecheck, production build and PWA validation passed on both platforms. Ubuntu JUnit recorded **7,156 tests, exactly 2 failures, 0 errors**, both loaded-history preservation cases. -1. preserve loaded history while suppressing an old-token success; -2. preserve loaded history while suppressing an old-token failure. +Issue #3352 added test-only head `85cf369ddd11dfe0a91052eb1523efbddf904a7c`, covering token rotation during an empty initial board-history read. A dependency-free runner transpiled and executed the actual production module: -No unrelated frontend test failed. +- before the retry correction: the API was called once and loading became false after rotation; +- after the correction: the API was called twice, old-token settlement was suppressed and the fresh-token result populated history. + +Hosted exact-head qualification remains authoritative; the supplemental runner does not replace it. ## Remaining gates -The production correction splits token-only invalidation from full identity reset. Exact-head lint, typecheck, build, complete Vitest on Ubuntu and Windows, full Required CI, Extended, Self-Test, and fresh-context review are still required. No merge, release, or deployment qualification is claimed. +Current production correction: `110edf3029450dc261b4feee30d6c78dd6e00405` 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. Review should focus on exact query capture, no retry after route clear and no retry loops. + +No merge, release or deployment qualification is claimed. From f1b55ff9da9cb4190bc37d5b205019dc02bb55cd Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 22:40:04 +0100 Subject: [PATCH 09/11] fix(audit): join token-refresh history retries --- frontend/taskdeck-web/src/store/auditStore.ts | 41 ++++++++++++---- .../tests/store/auditStoreOwnership.spec.ts | 48 ++++++++++--------- 2 files changed, 58 insertions(+), 31 deletions(-) diff --git a/frontend/taskdeck-web/src/store/auditStore.ts b/frontend/taskdeck-web/src/store/auditStore.ts index 2f63245b9..9c55af351 100644 --- a/frontend/taskdeck-web/src/store/auditStore.ts +++ b/frontend/taskdeck-web/src/store/auditStore.ts @@ -25,6 +25,7 @@ export const useAuditStore = defineStore('audit', () => { let credentialEpoch = 0 let currentRead: ReadOwner | null = null let currentRetry: ReadRetry | null = null + const successorReads = new Map>() function clampLimit(limit: number): number { if (limit < 1) return 1 @@ -60,18 +61,35 @@ export const useAuditStore = defineStore('audit', () => { error.value = null } - function retryEmptyActiveRead(): void { - const retry = currentRead && entries.value.length === 0 ? currentRetry : null - invalidateCurrentRead() - if (retry) { - void retry().catch(() => { - // The retried store action owns current error/toast state. - }) + async function awaitSuccessor(owner: ReadOwner): Promise { + const successor = successorReads.get(owner.token) + if (!successor) return false + + try { + await successor + } finally { + successorReads.delete(owner.token) } + return true + } + + function retryActiveRead(): void { + const retry = currentRead && currentRetry + ? { owner: currentRead, retry: currentRetry } + : null + invalidateCurrentRead() + if (!retry) return + + const successor = retry.retry() + successorReads.set(retry.owner.token, successor) + void successor.catch(() => { + // The retried store action owns current error/toast state. + }) } function resetForSession(): void { invalidateCurrentRead() + successorReads.clear() entries.value = [] } @@ -83,7 +101,7 @@ export const useAuditStore = defineStore('audit', () => { watch( () => session.token, - retryEmptyActiveRead, + retryActiveRead, { flush: 'sync' }, ) @@ -95,13 +113,18 @@ export const useAuditStore = defineStore('audit', () => { const owner = beginRead(retry) try { const result = await request() - if (!ownsRead(owner)) return + if (!ownsRead(owner)) { + await awaitSuccessor(owner) + return + } entries.value = result } catch (e: unknown) { if (ownsRead(owner)) { const msg = getErrorDisplay(e, fallbackMessage).message error.value = msg toast.error(msg) + } else if (await awaitSuccessor(owner)) { + return } throw e } finally { diff --git a/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts index a55b1c8dd..080ef89b1 100644 --- a/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts @@ -131,43 +131,52 @@ describe('auditStore async ownership', () => { expect(store.loading).toBe(false) }) - it('preserves loaded history while invalidating an old-token success on refresh', async () => { + it('reconciles loaded history after a same-user token refresh', async () => { store.entries = [entry('existing')] store.error = 'existing error' - const pending = deferred() - vi.mocked(auditApi.getBoardHistory).mockReturnValue(pending.promise) + const oldRead = deferred() + const freshRead = deferred() + vi.mocked(auditApi.getBoardHistory) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) const request = store.fetchBoardHistory('board-old') expect(store.loading).toBe(true) session.token = 'token-b' + expect(auditApi.getBoardHistory).toHaveBeenCalledTimes(2) expect(store.entries.map(item => item.id)).toEqual(['existing']) expect(store.error).toBeNull() - expect(store.loading).toBe(false) + expect(store.loading).toBe(true) - pending.resolve([entry('old-token')]) + oldRead.resolve([entry('old-token')]) + freshRead.resolve([entry('fresh-token')]) await request - expect(store.entries.map(item => item.id)).toEqual(['existing']) + expect(store.entries.map(item => item.id)).toEqual(['fresh-token']) expect(store.error).toBeNull() expect(store.loading).toBe(false) }) - it('preserves loaded history while suppressing an old-token failure', async () => { + it('surfaces a replacement history failure after a same-user token refresh', async () => { store.entries = [entry('existing')] - const pending = deferred() - vi.mocked(auditApi.getEntityHistory).mockReturnValue(pending.promise) + const oldRead = deferred() + const freshRead = deferred() + vi.mocked(auditApi.getEntityHistory) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) const request = store.fetchEntityHistory('Card', 'card-old') session.token = 'token-b' - pending.reject(new Error('old-token failure')) - await expect(request).rejects.toThrow('old-token failure') + oldRead.reject(new Error('old-token failure')) + freshRead.reject(new Error('replacement failure')) + await expect(request).rejects.toThrow('replacement failure') expect(store.entries.map(item => item.id)).toEqual(['existing']) - expect(store.error).toBeNull() + expect(store.error).toBe('replacement failure') expect(store.loading).toBe(false) - expect(toastMocks.error).not.toHaveBeenCalled() + expect(toastMocks.error).toHaveBeenCalledTimes(1) }) it('retries an empty initial history read after same-user token rotation', async () => { @@ -185,17 +194,12 @@ describe('auditStore async ownership', () => { expect(store.loading).toBe(true) oldRead.resolve([entry('old-token')]) + freshRead.resolve([entry('fresh-token')]) await request - expect(store.entries).toEqual([]) - expect(store.loading).toBe(true) - - freshRead.resolve([entry('fresh-token')]) - await vi.waitFor(() => { - expect(store.entries.map(item => item.id)).toEqual(['fresh-token']) - expect(store.loading).toBe(false) - expect(store.error).toBeNull() - }) + expect(store.entries.map(item => item.id)).toEqual(['fresh-token']) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() }) it('clears history on identity replacement and suppresses late settlement', async () => { From 1e291ccf3bf27102c44f3fa4893d38763ddb7047 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 23:01:04 +0100 Subject: [PATCH 10/11] fix(audit): let token refresh successors settle reads --- frontend/taskdeck-web/src/store/auditStore.ts | 51 ++++++++++++++----- .../tests/store/auditStoreOwnership.spec.ts | 24 +++++++++ 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/frontend/taskdeck-web/src/store/auditStore.ts b/frontend/taskdeck-web/src/store/auditStore.ts index 9c55af351..edabd648e 100644 --- a/frontend/taskdeck-web/src/store/auditStore.ts +++ b/frontend/taskdeck-web/src/store/auditStore.ts @@ -20,12 +20,13 @@ export const useAuditStore = defineStore('audit', () => { interface ReadOwner { epoch: number token: symbol + successorReady: Promise> + resolveSuccessor: (successor: Promise) => void } let credentialEpoch = 0 let currentRead: ReadOwner | null = null let currentRetry: ReadRetry | null = null - const successorReads = new Map>() function clampLimit(limit: number): number { if (limit < 1) return 1 @@ -34,7 +35,16 @@ export const useAuditStore = defineStore('audit', () => { } function beginRead(retry: ReadRetry): ReadOwner { - const owner = { epoch: credentialEpoch, token: Symbol('audit-history') } + let resolveSuccessor!: (successor: Promise) => void + const successorReady = new Promise>((resolve) => { + resolveSuccessor = resolve + }) + const owner = { + epoch: credentialEpoch, + token: Symbol('audit-history'), + successorReady, + resolveSuccessor, + } currentRead = owner currentRetry = retry error.value = null @@ -62,14 +72,8 @@ export const useAuditStore = defineStore('audit', () => { } async function awaitSuccessor(owner: ReadOwner): Promise { - const successor = successorReads.get(owner.token) - if (!successor) return false - - try { - await successor - } finally { - successorReads.delete(owner.token) - } + if (owner.epoch === credentialEpoch) return false + await owner.successorReady.then((successor) => successor) return true } @@ -80,16 +84,28 @@ export const useAuditStore = defineStore('audit', () => { invalidateCurrentRead() if (!retry) return - const successor = retry.retry() - successorReads.set(retry.owner.token, successor) + let resolveSuccessor!: () => void + let rejectSuccessor!: (reason: unknown) => void + const successor = new Promise((resolve, reject) => { + resolveSuccessor = resolve + rejectSuccessor = reject + }) + retry.owner.resolveSuccessor(successor) + + try { + void retry.retry().then(resolveSuccessor, rejectSuccessor) + } catch (error) { + rejectSuccessor(error) + } void successor.catch(() => { // The retried store action owns current error/toast state. }) } function resetForSession(): void { + const retiredRead = currentRead invalidateCurrentRead() - successorReads.clear() + retiredRead?.resolveSuccessor(Promise.resolve()) entries.value = [] } @@ -112,7 +128,14 @@ export const useAuditStore = defineStore('audit', () => { ): Promise { const owner = beginRead(retry) try { - const result = await request() + const requestPromise = request() + const outcome = await Promise.race([ + requestPromise.then((result) => ({ kind: 'request' as const, result })), + owner.successorReady.then((successor) => successor.then(() => ({ kind: 'successor' as const }))), + ]) + if (outcome.kind === 'successor') return + + const result = outcome.result if (!ownsRead(owner)) { await awaitSuccessor(owner) return diff --git a/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts index 080ef89b1..34d6b34b2 100644 --- a/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts @@ -159,6 +159,30 @@ describe('auditStore async ownership', () => { expect(store.loading).toBe(false) }) + it('lets a completed token-refresh successor settle a stalled retired read', async () => { + store.entries = [entry('existing')] + const oldRead = deferred() + const freshRead = deferred() + vi.mocked(auditApi.getBoardHistory) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) + + const request = store.fetchBoardHistory('board-stalled') + session.token = 'token-b' + + expect(auditApi.getBoardHistory).toHaveBeenCalledTimes(2) + + freshRead.resolve([entry('fresh-token')]) + await request + + expect(store.entries.map(item => item.id)).toEqual(['fresh-token']) + expect(store.loading).toBe(false) + + oldRead.resolve([entry('old-token')]) + await Promise.resolve() + expect(store.entries.map(item => item.id)).toEqual(['fresh-token']) + }) + it('surfaces a replacement history failure after a same-user token refresh', async () => { store.entries = [entry('existing')] const oldRead = deferred() From 4783c8ad80a0261a05a0778955a7f337c81ff7c2 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 23:28:43 +0100 Subject: [PATCH 11/11] fix(audit): settle superseded read owners --- frontend/taskdeck-web/src/store/auditStore.ts | 15 +++++++++----- .../tests/store/auditStoreOwnership.spec.ts | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/frontend/taskdeck-web/src/store/auditStore.ts b/frontend/taskdeck-web/src/store/auditStore.ts index edabd648e..d97132cc2 100644 --- a/frontend/taskdeck-web/src/store/auditStore.ts +++ b/frontend/taskdeck-web/src/store/auditStore.ts @@ -20,13 +20,14 @@ export const useAuditStore = defineStore('audit', () => { interface ReadOwner { epoch: number token: symbol - successorReady: Promise> + successorReady: Promise<{ successor: Promise }> resolveSuccessor: (successor: Promise) => void } let credentialEpoch = 0 let currentRead: ReadOwner | null = null let currentRetry: ReadRetry | null = null + const retiredReads = new Set() function clampLimit(limit: number): number { if (limit < 1) return 1 @@ -35,9 +36,10 @@ export const useAuditStore = defineStore('audit', () => { } function beginRead(retry: ReadRetry): ReadOwner { + if (currentRead) retiredReads.add(currentRead) let resolveSuccessor!: (successor: Promise) => void - const successorReady = new Promise>((resolve) => { - resolveSuccessor = resolve + const successorReady = new Promise<{ successor: Promise }>((resolve) => { + resolveSuccessor = (successor) => resolve({ successor }) }) const owner = { epoch: credentialEpoch, @@ -57,6 +59,7 @@ export const useAuditStore = defineStore('audit', () => { } function finishRead(owner: ReadOwner): void { + retiredReads.delete(owner) if (!ownsRead(owner)) return currentRead = null currentRetry = null @@ -64,6 +67,8 @@ export const useAuditStore = defineStore('audit', () => { } function invalidateCurrentRead(): void { + for (const owner of retiredReads) owner.resolveSuccessor(Promise.resolve()) + retiredReads.clear() credentialEpoch += 1 currentRead = null currentRetry = null @@ -73,7 +78,7 @@ export const useAuditStore = defineStore('audit', () => { async function awaitSuccessor(owner: ReadOwner): Promise { if (owner.epoch === credentialEpoch) return false - await owner.successorReady.then((successor) => successor) + await owner.successorReady.then(({ successor }) => successor) return true } @@ -131,7 +136,7 @@ export const useAuditStore = defineStore('audit', () => { const requestPromise = request() const outcome = await Promise.race([ requestPromise.then((result) => ({ kind: 'request' as const, result })), - owner.successorReady.then((successor) => successor.then(() => ({ kind: 'successor' as const }))), + owner.successorReady.then(({ successor }) => successor.then(() => ({ kind: 'successor' as const }))), ]) if (outcome.kind === 'successor') return diff --git a/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts index 34d6b34b2..17f0bbe1b 100644 --- a/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts @@ -94,6 +94,26 @@ describe('auditStore async ownership', () => { expect(store.error).toBeNull() }) + it('settles a superseded query after a later token rotation', async () => { + const firstRead = deferred() + const secondRead = deferred() + vi.mocked(auditApi.getBoardHistory).mockReturnValue(firstRead.promise) + vi.mocked(auditApi.getEntityHistory) + .mockReturnValueOnce(secondRead.promise) + .mockResolvedValue([entry('new-entity')]) + + const firstRequest = store.fetchBoardHistory('board-old') + const secondRequest = store.fetchEntityHistory('Card', 'card-new') + session.token = 'token-b' + + firstRead.resolve([entry('old-board')]) + await expect(firstRequest).resolves.toBeUndefined() + + secondRead.resolve([entry('new-entity')]) + await secondRequest + expect(store.entries.map(item => item.id)).toEqual(['new-entity']) + }) + it('suppresses stale failure UI after a newer query succeeds while preserving rejection', async () => { const oldBoard = deferred() const newUser = deferred()