From e102426e50f4307de5c1a4396afebb44ae063d99 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:24:44 +0100 Subject: [PATCH 01/10] test(metrics): reproduce stale read ownership races --- .../tests/store/metricsStoreOwnership.spec.ts | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts new file mode 100644 index 000000000..4a8e16a94 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { metricsApi } from '../../api/metricsApi' +import { useMetricsStore } from '../../store/metricsStore' +import { useSessionStore } from '../../store/sessionStore' +import type { BoardForecastResponse, BoardMetricsResponse } from '../../types/metrics' + +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/metricsApi', () => ({ + metricsApi: { + getBoardMetrics: vi.fn(), + getBoardForecast: vi.fn(), + exportBoardMetricsCsv: 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 metrics(boardId: string): BoardMetricsResponse { + return { + boardId, + from: '2026-08-01T00:00:00Z', + to: '2026-09-01T00:00:00Z', + throughput: [], + averageCycleTimeDays: 0, + cycleTimeEntries: [], + wipSnapshots: [], + totalWip: 0, + blockedCount: 0, + blockedCards: [], + } +} + +function forecast(boardId: string): BoardForecastResponse { + return { + boardId, + remainingCards: 0, + completedCards: 0, + averageThroughputPerDay: 0, + throughputStdDev: 0, + averageCycleTimeDays: 0, + estimatedCompletionDate: null, + confidenceBand: null, + dataPointCount: 0, + historyDaysUsed: 30, + assumptions: [], + caveats: [], + } +} + +describe('metricsStore async ownership', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'user-a' + session.token = 'token-a' + store = useMetricsStore() + vi.clearAllMocks() + }) + + it('keeps the newest metrics request when responses settle in reverse order', async () => { + const older = deferred() + const newer = deferred() + vi.mocked(metricsApi.getBoardMetrics) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise) + + const oldRequest = store.fetchBoardMetrics({ boardId: 'board-old' }) + const newRequest = store.fetchBoardMetrics({ boardId: 'board-new' }) + newer.resolve(metrics('board-new')) + await newRequest + older.resolve(metrics('board-old')) + await oldRequest + + expect(store.metrics?.boardId).toBe('board-new') + }) + + it('keeps the newest forecast request when responses settle in reverse order', async () => { + const older = deferred() + const newer = deferred() + vi.mocked(metricsApi.getBoardForecast) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise) + + const oldRequest = store.fetchBoardForecast({ boardId: 'board-old' }) + const newRequest = store.fetchBoardForecast({ boardId: 'board-new' }) + newer.resolve(forecast('board-new')) + await newRequest + older.resolve(forecast('board-old')) + await oldRequest + + expect(store.forecast?.boardId).toBe('board-new') + }) + + it('suppresses stale metrics failure UI after a newer success while preserving rejection', async () => { + const older = deferred() + const newer = deferred() + vi.mocked(metricsApi.getBoardMetrics) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise) + + const oldRequest = store.fetchBoardMetrics({ boardId: 'board-old' }) + const newRequest = store.fetchBoardMetrics({ boardId: 'board-new' }) + newer.resolve(metrics('board-new')) + await newRequest + older.reject(new Error('stale metrics failure')) + await expect(oldRequest).rejects.toThrow('stale metrics failure') + + expect(store.metrics?.boardId).toBe('board-new') + expect(store.error).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('does not let an older metrics finally clear the current metrics loading owner', async () => { + const older = deferred() + const newer = deferred() + vi.mocked(metricsApi.getBoardMetrics) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise) + + const oldRequest = store.fetchBoardMetrics({ boardId: 'board-old' }) + const newRequest = store.fetchBoardMetrics({ boardId: 'board-new' }) + older.resolve(metrics('board-old')) + await oldRequest + expect(store.loading).toBe(true) + + newer.resolve(metrics('board-new')) + await newRequest + expect(store.loading).toBe(false) + }) + + it('keeps metrics and forecast lanes independently concurrent', async () => { + const pendingMetrics = deferred() + const pendingForecast = deferred() + vi.mocked(metricsApi.getBoardMetrics).mockReturnValue(pendingMetrics.promise) + vi.mocked(metricsApi.getBoardForecast).mockReturnValue(pendingForecast.promise) + + const metricsRequest = store.fetchBoardMetrics({ boardId: 'board-a' }) + const forecastRequest = store.fetchBoardForecast({ boardId: 'board-a' }) + + pendingMetrics.resolve(metrics('board-a')) + await metricsRequest + expect(store.loading).toBe(false) + expect(store.forecastLoading).toBe(true) + + pendingForecast.resolve(forecast('board-a')) + await forecastRequest + expect(store.forecastLoading).toBe(false) + }) + + it('$reset invalidates pending success and failure settlements', async () => { + const pendingMetrics = deferred() + const pendingForecast = deferred() + vi.mocked(metricsApi.getBoardMetrics).mockReturnValue(pendingMetrics.promise) + vi.mocked(metricsApi.getBoardForecast).mockReturnValue(pendingForecast.promise) + + const metricsRequest = store.fetchBoardMetrics({ boardId: 'board-old' }) + const forecastRequest = store.fetchBoardForecast({ boardId: 'board-old' }) + store.$reset() + + pendingMetrics.resolve(metrics('board-old')) + pendingForecast.reject(new Error('stale forecast failure')) + await metricsRequest + await expect(forecastRequest).rejects.toThrow('stale forecast failure') + + expect(store.metrics).toBeNull() + expect(store.forecast).toBeNull() + expect(store.loading).toBe(false) + expect(store.forecastLoading).toBe(false) + expect(store.error).toBeNull() + expect(store.forecastError).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('clears both lanes and suppresses late settlements on same-user token rotation', async () => { + store.metrics = metrics('existing') + store.forecast = forecast('existing') + const pendingMetrics = deferred() + const pendingForecast = deferred() + vi.mocked(metricsApi.getBoardMetrics).mockReturnValue(pendingMetrics.promise) + vi.mocked(metricsApi.getBoardForecast).mockReturnValue(pendingForecast.promise) + + const metricsRequest = store.fetchBoardMetrics({ boardId: 'board-old' }) + const forecastRequest = store.fetchBoardForecast({ boardId: 'board-old' }) + session.token = 'token-b' + + expect(store.metrics).toBeNull() + expect(store.forecast).toBeNull() + expect(store.loading).toBe(false) + expect(store.forecastLoading).toBe(false) + + pendingMetrics.resolve(metrics('old-token')) + pendingForecast.reject(new Error('old-token forecast failure')) + await metricsRequest + await expect(forecastRequest).rejects.toThrow('old-token forecast failure') + + expect(store.metrics).toBeNull() + expect(store.forecast).toBeNull() + expect(store.error).toBeNull() + expect(store.forecastError).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) +}) From 8029c1d22d56e3af5a6a9744cc38443d05be3626 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:36:59 +0100 Subject: [PATCH 02/10] fix(metrics): bind reads to lane and credential lifetimes --- .../2026-09-21-metrics-read-ownership.md | 45 +++++++ .../taskdeck-web/src/store/metricsStore.ts | 122 +++++++++++++----- 2 files changed, 137 insertions(+), 30 deletions(-) create mode 100644 docs/analysis/2026-09-21-metrics-read-ownership.md diff --git a/docs/analysis/2026-09-21-metrics-read-ownership.md b/docs/analysis/2026-09-21-metrics-read-ownership.md new file mode 100644 index 000000000..490e64c44 --- /dev/null +++ b/docs/analysis/2026-09-21-metrics-read-ownership.md @@ -0,0 +1,45 @@ +# Metrics and forecast request ownership + +Status: corrective draft for #3346 / PR #3347, based on `main` +`307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`. + +## Reproduced defect + +Board metrics and forecast requests own separate visible surfaces, but each method +previously committed every response, failure, toast and `finally`. A board/date +selection change could therefore restore an older result or clear the current +lane's loading state. `$reset()` cleared refs without invalidating requests +already in flight, and the store had no same-user token/session replacement +boundary. + +## Contract + +- Metrics and forecast retain independent latest-request owners. +- A newer request retires only the previous owner in the same lane. +- `$reset()` and identity, token, authentication or demo-session replacement + synchronously advance one credential epoch and clear both lanes. +- Stale requests still resolve or reject to their original callers, but cannot + write results, errors, toasts, loading or final state. +- A current failure preserves the previous result and the existing public + error/toast/rejection behavior. +- Metrics completion cannot clear forecast loading, and forecast completion + cannot clear metrics loading. +- Demo messages, endpoints, query types and the public store API remain unchanged. + +This is client-state integrity. It does not cancel transport or change server +metrics authorization. + +## Evidence and remaining gates + +The committed real Pinia/Vitest suite covers seven deferred schedules. A bounded +supplemental runner transpiles and executes the actual store with only +framework/API/session boundaries stubbed: + +- unchanged `main`: 1/7 passed, with only the independent-lane control green; +- corrected source: 7/7 passed. + +The supplemental runner is not committed and does not replace project +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, complete Required CI/Extended/Self-Test workflows, and a +fresh-context review. No merge, release or deployment qualification is claimed. diff --git a/frontend/taskdeck-web/src/store/metricsStore.ts b/frontend/taskdeck-web/src/store/metricsStore.ts index 3006fa48b..625eb0550 100644 --- a/frontend/taskdeck-web/src/store/metricsStore.ts +++ b/frontend/taskdeck-web/src/store/metricsStore.ts @@ -1,13 +1,15 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, watch } from 'vue' import { metricsApi } from '../api/metricsApi' import { useToastStore } from './toastStore' +import { useSessionStore } from './sessionStore' import { isDemoMode } from '../utils/demoMode' import { getErrorDisplay } from '../composables/useErrorMapper' import type { BoardMetricsResponse, BoardForecastResponse, MetricsQuery, ForecastQuery } from '../types/metrics' export const useMetricsStore = defineStore('metrics', () => { const toast = useToastStore() + const session = useSessionStore() const metrics = ref(null) const loading = ref(false) @@ -17,61 +19,121 @@ export const useMetricsStore = defineStore('metrics', () => { const forecastLoading = ref(false) const forecastError = ref(null) + interface RequestOwner { + epoch: number + token: symbol + } + + let credentialEpoch = 0 + let metricsOwner: RequestOwner | null = null + let forecastOwner: RequestOwner | null = null + + function beginMetricsRequest(): RequestOwner { + const owner = { epoch: credentialEpoch, token: Symbol('board-metrics') } + metricsOwner = owner + loading.value = true + error.value = null + return owner + } + + function ownsMetricsRequest(owner: RequestOwner): boolean { + return owner.epoch === credentialEpoch && metricsOwner?.token === owner.token + } + + function finishMetricsRequest(owner: RequestOwner): void { + if (!ownsMetricsRequest(owner)) return + metricsOwner = null + loading.value = false + } + + function beginForecastRequest(): RequestOwner { + const owner = { epoch: credentialEpoch, token: Symbol('board-forecast') } + forecastOwner = owner + forecastLoading.value = true + forecastError.value = null + return owner + } + + function ownsForecastRequest(owner: RequestOwner): boolean { + return owner.epoch === credentialEpoch && forecastOwner?.token === owner.token + } + + function finishForecastRequest(owner: RequestOwner): void { + if (!ownsForecastRequest(owner)) return + forecastOwner = null + forecastLoading.value = false + } + + function $reset(): void { + credentialEpoch += 1 + metricsOwner = null + forecastOwner = null + metrics.value = null + loading.value = false + error.value = null + forecast.value = null + forecastLoading.value = false + forecastError.value = null + } + + watch( + () => [session.userId, session.token, session.isAuthenticated, session.isDemo], + $reset, + { flush: 'sync' }, + ) + async function fetchBoardMetrics(query: MetricsQuery) { if (isDemoMode) { - loading.value = true - error.value = null - metrics.value = null + metricsOwner = null loading.value = false error.value = 'Metrics are not available in demo mode.' + metrics.value = null return } + + const owner = beginMetricsRequest() try { - loading.value = true - error.value = null - metrics.value = await metricsApi.getBoardMetrics(query) + const result = await metricsApi.getBoardMetrics(query) + if (!ownsMetricsRequest(owner)) return + metrics.value = result } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch board metrics').message - error.value = msg - toast.error(msg) + if (ownsMetricsRequest(owner)) { + const msg = getErrorDisplay(e, 'Failed to fetch board metrics').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishMetricsRequest(owner) } } async function fetchBoardForecast(query: ForecastQuery) { if (isDemoMode) { - forecastLoading.value = true - forecastError.value = null - forecast.value = null + forecastOwner = null forecastLoading.value = false forecastError.value = 'Forecast is not available in demo mode.' + forecast.value = null return } + + const owner = beginForecastRequest() try { - forecastLoading.value = true - forecastError.value = null - forecast.value = await metricsApi.getBoardForecast(query) + const result = await metricsApi.getBoardForecast(query) + if (!ownsForecastRequest(owner)) return + forecast.value = result } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch board forecast').message - forecastError.value = msg - toast.error(msg) + if (ownsForecastRequest(owner)) { + const msg = getErrorDisplay(e, 'Failed to fetch board forecast').message + forecastError.value = msg + toast.error(msg) + } throw e } finally { - forecastLoading.value = false + finishForecastRequest(owner) } } - function $reset() { - metrics.value = null - loading.value = false - error.value = null - forecast.value = null - forecastLoading.value = false - forecastError.value = null - } - return { metrics, loading, From bec559bc47a31d32162131359ac763daad371585 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:36:16 +0100 Subject: [PATCH 03/10] test(metrics): preserve dashboard across token refresh --- .../src/tests/store/metricsStoreOwnership.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts index 4a8e16a94..af423ce66 100644 --- a/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts @@ -209,7 +209,7 @@ describe('metricsStore async ownership', () => { expect(toastMocks.error).not.toHaveBeenCalled() }) - it('clears both lanes and suppresses late settlements on same-user token rotation', async () => { + it('preserves loaded dashboard data while invalidating old-token work on refresh', async () => { store.metrics = metrics('existing') store.forecast = forecast('existing') const pendingMetrics = deferred() @@ -221,8 +221,8 @@ describe('metricsStore async ownership', () => { const forecastRequest = store.fetchBoardForecast({ boardId: 'board-old' }) session.token = 'token-b' - expect(store.metrics).toBeNull() - expect(store.forecast).toBeNull() + expect(store.metrics?.boardId).toBe('existing') + expect(store.forecast?.boardId).toBe('existing') expect(store.loading).toBe(false) expect(store.forecastLoading).toBe(false) @@ -231,8 +231,8 @@ describe('metricsStore async ownership', () => { await metricsRequest await expect(forecastRequest).rejects.toThrow('old-token forecast failure') - expect(store.metrics).toBeNull() - expect(store.forecast).toBeNull() + expect(store.metrics?.boardId).toBe('existing') + expect(store.forecast?.boardId).toBe('existing') expect(store.error).toBeNull() expect(store.forecastError).toBeNull() expect(toastMocks.error).not.toHaveBeenCalled() From 6561c0c895b20abb7f9ce5815ee52ee5958f6189 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:42:08 +0100 Subject: [PATCH 04/10] fix(metrics): preserve dashboard across token refresh --- .../taskdeck-web/src/store/metricsStore.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/frontend/taskdeck-web/src/store/metricsStore.ts b/frontend/taskdeck-web/src/store/metricsStore.ts index 625eb0550..b57cf3d13 100644 --- a/frontend/taskdeck-web/src/store/metricsStore.ts +++ b/frontend/taskdeck-web/src/store/metricsStore.ts @@ -64,24 +64,34 @@ export const useMetricsStore = defineStore('metrics', () => { forecastLoading.value = false } - function $reset(): void { + function invalidateRequests(): void { credentialEpoch += 1 metricsOwner = null forecastOwner = null - metrics.value = null loading.value = false error.value = null - forecast.value = null forecastLoading.value = false forecastError.value = null } + function $reset(): void { + invalidateRequests() + metrics.value = null + forecast.value = null + } + watch( - () => [session.userId, session.token, session.isAuthenticated, session.isDemo], + () => [session.userId, session.isAuthenticated, session.isDemo], $reset, { flush: 'sync' }, ) + watch( + () => session.token, + invalidateRequests, + { flush: 'sync' }, + ) + async function fetchBoardMetrics(query: MetricsQuery) { if (isDemoMode) { metricsOwner = null From 6f7200b55b0ded1b80d34c2134b107c7a589bc05 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:43:20 +0100 Subject: [PATCH 05/10] docs(metrics): distinguish token rotation from identity reset --- .../2026-09-21-metrics-read-ownership.md | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/analysis/2026-09-21-metrics-read-ownership.md b/docs/analysis/2026-09-21-metrics-read-ownership.md index 490e64c44..b09b02216 100644 --- a/docs/analysis/2026-09-21-metrics-read-ownership.md +++ b/docs/analysis/2026-09-21-metrics-read-ownership.md @@ -12,12 +12,20 @@ lane's loading state. `$reset()` cleared refs without invalidating requests already in flight, and the store had no same-user token/session replacement boundary. +A later ownership review found that treating a token-only refresh as a complete +reset creates a separate false-empty state. `MetricsView` fetches when the board +or range changes, so session extension on an unchanged route cleared the current +dashboard without triggering another read. + ## Contract - Metrics and forecast retain independent latest-request owners. - A newer request retires only the previous owner in the same lane. -- `$reset()` and identity, token, authentication or demo-session replacement - synchronously advance one credential epoch and clear both lanes. +- `$reset()` and user-identity, authentication or demo-session replacement + synchronously advance one credential epoch and clear both data surfaces. +- A token-only rotation advances that same request epoch and clears transient + loading/errors, but preserves already loaded metrics and forecast for the + unchanged user, board and route. - Stale requests still resolve or reject to their original callers, but cannot write results, errors, toasts, loading or final state. - A current failure preserves the previous result and the existing public @@ -31,15 +39,22 @@ metrics authorization. ## Evidence and remaining gates -The committed real Pinia/Vitest suite covers seven deferred schedules. A bounded -supplemental runner transpiles and executes the actual store with only +The initial committed real Pinia/Vitest suite covers seven deferred schedules. A +bounded supplemental runner transpiles and executes the actual store with only framework/API/session boundaries stubbed: - unchanged `main`: 1/7 passed, with only the independent-lane control green; -- corrected source: 7/7 passed. +- initial corrected source: 7/7 passed. + +Review-regression head `f9f6bc9479ec7d211077b545be95a64cf63e65ae` +changed the token-rotation contract from clearing to preserving loaded dashboard +data. Ubuntu passed lint, typecheck, build and PWA validation; its JUnit artifact +ran all seven ownership cases and failed only +`preserves loaded dashboard data while invalidating old-token work on refresh`, +with the current metrics value cleared instead of retaining board `existing`. The supplemental runner is not committed and does not replace project -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, complete Required CI/Extended/Self-Test workflows, and a -fresh-context review. No merge, release or deployment qualification is claimed. +qualification. The current production correction requires exact-head lint, +typecheck, production build, full Vitest on Ubuntu and Windows, complete Required +CI/Extended/Self-Test workflows, and a fresh-context review. No merge, release or +deployment qualification is claimed. From 7711dc28282c0d69f0c56a76b0658fe9688eb916 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:30:28 +0100 Subject: [PATCH 06/10] test(metrics): retry empty initial reads after token refresh --- .../tests/store/metricsStoreOwnership.spec.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts index af423ce66..cdcb784f8 100644 --- a/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts @@ -237,4 +237,50 @@ describe('metricsStore async ownership', () => { expect(store.forecastError).toBeNull() expect(toastMocks.error).not.toHaveBeenCalled() }) + + it('retries empty initial metrics and forecast reads after same-user token rotation', async () => { + const oldMetrics = deferred() + const freshMetrics = deferred() + const oldForecast = deferred() + const freshForecast = deferred() + vi.mocked(metricsApi.getBoardMetrics) + .mockReturnValueOnce(oldMetrics.promise) + .mockReturnValueOnce(freshMetrics.promise) + vi.mocked(metricsApi.getBoardForecast) + .mockReturnValueOnce(oldForecast.promise) + .mockReturnValueOnce(freshForecast.promise) + + const metricsRequest = store.fetchBoardMetrics({ boardId: 'board-a' }) + const forecastRequest = store.fetchBoardForecast({ boardId: 'board-a' }) + session.token = 'token-b' + + expect(metricsApi.getBoardMetrics).toHaveBeenCalledTimes(2) + expect(metricsApi.getBoardForecast).toHaveBeenCalledTimes(2) + expect(store.metrics).toBeNull() + expect(store.forecast).toBeNull() + expect(store.loading).toBe(true) + expect(store.forecastLoading).toBe(true) + + oldMetrics.resolve(metrics('old-token')) + oldForecast.reject(new Error('old-token forecast failure')) + await metricsRequest + await expect(forecastRequest).rejects.toThrow('old-token forecast failure') + + expect(store.metrics).toBeNull() + expect(store.forecast).toBeNull() + expect(store.loading).toBe(true) + expect(store.forecastLoading).toBe(true) + expect(store.error).toBeNull() + expect(store.forecastError).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + + freshMetrics.resolve(metrics('fresh-token')) + freshForecast.resolve(forecast('fresh-token')) + await vi.waitFor(() => { + expect(store.metrics?.boardId).toBe('fresh-token') + expect(store.forecast?.boardId).toBe('fresh-token') + expect(store.loading).toBe(false) + expect(store.forecastLoading).toBe(false) + }) + }) }) From 34c07ad69b11c3ae34c530216b0d2dcc439e3b11 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:40:40 +0100 Subject: [PATCH 07/10] fix(metrics): retry empty active reads after token refresh --- .../taskdeck-web/src/store/metricsStore.ts | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/frontend/taskdeck-web/src/store/metricsStore.ts b/frontend/taskdeck-web/src/store/metricsStore.ts index b57cf3d13..117ac92e1 100644 --- a/frontend/taskdeck-web/src/store/metricsStore.ts +++ b/frontend/taskdeck-web/src/store/metricsStore.ts @@ -19,6 +19,8 @@ export const useMetricsStore = defineStore('metrics', () => { const forecastLoading = ref(false) const forecastError = ref(null) + type ReadRetry = () => Promise + interface RequestOwner { epoch: number token: symbol @@ -27,10 +29,13 @@ export const useMetricsStore = defineStore('metrics', () => { let credentialEpoch = 0 let metricsOwner: RequestOwner | null = null let forecastOwner: RequestOwner | null = null + let metricsRetry: ReadRetry | null = null + let forecastRetry: ReadRetry | null = null - function beginMetricsRequest(): RequestOwner { + function beginMetricsRequest(retry: ReadRetry): RequestOwner { const owner = { epoch: credentialEpoch, token: Symbol('board-metrics') } metricsOwner = owner + metricsRetry = retry loading.value = true error.value = null return owner @@ -43,12 +48,14 @@ export const useMetricsStore = defineStore('metrics', () => { function finishMetricsRequest(owner: RequestOwner): void { if (!ownsMetricsRequest(owner)) return metricsOwner = null + metricsRetry = null loading.value = false } - function beginForecastRequest(): RequestOwner { + function beginForecastRequest(retry: ReadRetry): RequestOwner { const owner = { epoch: credentialEpoch, token: Symbol('board-forecast') } forecastOwner = owner + forecastRetry = retry forecastLoading.value = true forecastError.value = null return owner @@ -61,6 +68,7 @@ export const useMetricsStore = defineStore('metrics', () => { function finishForecastRequest(owner: RequestOwner): void { if (!ownsForecastRequest(owner)) return forecastOwner = null + forecastRetry = null forecastLoading.value = false } @@ -68,12 +76,31 @@ export const useMetricsStore = defineStore('metrics', () => { credentialEpoch += 1 metricsOwner = null forecastOwner = null + metricsRetry = null + forecastRetry = null loading.value = false error.value = null forecastLoading.value = false forecastError.value = null } + function retryEmptyActiveRequests(): void { + const pendingMetricsRetry = metricsOwner && metrics.value === null ? metricsRetry : null + const pendingForecastRetry = forecastOwner && forecast.value === null ? forecastRetry : null + + invalidateRequests() + if (pendingMetricsRetry) { + void pendingMetricsRetry().catch(() => { + // The retried store action owns current error/toast state. + }) + } + if (pendingForecastRetry) { + void pendingForecastRetry().catch(() => { + // The retried store action owns current error/toast state. + }) + } + } + function $reset(): void { invalidateRequests() metrics.value = null @@ -88,20 +115,21 @@ export const useMetricsStore = defineStore('metrics', () => { watch( () => session.token, - invalidateRequests, + retryEmptyActiveRequests, { flush: 'sync' }, ) async function fetchBoardMetrics(query: MetricsQuery) { if (isDemoMode) { metricsOwner = null + metricsRetry = null loading.value = false error.value = 'Metrics are not available in demo mode.' metrics.value = null return } - const owner = beginMetricsRequest() + const owner = beginMetricsRequest(() => fetchBoardMetrics(query)) try { const result = await metricsApi.getBoardMetrics(query) if (!ownsMetricsRequest(owner)) return @@ -121,13 +149,14 @@ export const useMetricsStore = defineStore('metrics', () => { async function fetchBoardForecast(query: ForecastQuery) { if (isDemoMode) { forecastOwner = null + forecastRetry = null forecastLoading.value = false forecastError.value = 'Forecast is not available in demo mode.' forecast.value = null return } - const owner = beginForecastRequest() + const owner = beginForecastRequest(() => fetchBoardForecast(query)) try { const result = await metricsApi.getBoardForecast(query) if (!ownsForecastRequest(owner)) return From 020564961a763be91b369d54eb505b141144f5a9 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:49:12 +0100 Subject: [PATCH 08/10] docs(metrics): record empty-read retry contract --- .../2026-09-21-metrics-read-ownership.md | 87 ++++++++----------- 1 file changed, 37 insertions(+), 50 deletions(-) diff --git a/docs/analysis/2026-09-21-metrics-read-ownership.md b/docs/analysis/2026-09-21-metrics-read-ownership.md index b09b02216..92e6ebb20 100644 --- a/docs/analysis/2026-09-21-metrics-read-ownership.md +++ b/docs/analysis/2026-09-21-metrics-read-ownership.md @@ -1,60 +1,47 @@ # Metrics and forecast request ownership -Status: corrective draft for #3346 / PR #3347, based on `main` -`307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`. +Status: corrective draft for #3346 / PR #3347. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`. -## Reproduced defect +## Reproduced defects -Board metrics and forecast requests own separate visible surfaces, but each method -previously committed every response, failure, toast and `finally`. A board/date -selection change could therefore restore an older result or clear the current -lane's loading state. `$reset()` cleared refs without invalidating requests -already in flight, and the store had no same-user token/session replacement -boundary. +Board metrics and forecast own separate visible surfaces, but the original store committed every response, failure, toast and `finally`. Board/date changes could restore older results or clear current loading, `$reset()` did not invalidate in-flight work, and the store had no session boundary. -A later ownership review found that treating a token-only refresh as a complete -reset creates a separate false-empty state. `MetricsView` fetches when the board -or range changes, so session extension on an unchanged route cleared the current -dashboard without triggering another read. +Review then exposed two token-refresh defects: + +1. full reset on same-user refresh cleared already loaded dashboard data; +2. preservation alone stranded an empty first load because the old request was retired and the unchanged route did not refetch. ## Contract - Metrics and forecast retain independent latest-request owners. - A newer request retires only the previous owner in the same lane. -- `$reset()` and user-identity, authentication or demo-session replacement - synchronously advance one credential epoch and clear both data surfaces. -- A token-only rotation advances that same request epoch and clears transient - loading/errors, but preserves already loaded metrics and forecast for the - unchanged user, board and route. -- Stale requests still resolve or reject to their original callers, but cannot - write results, errors, toasts, loading or final state. -- A current failure preserves the previous result and the existing public - error/toast/rejection behavior. -- Metrics completion cannot clear forecast loading, and forecast completion - cannot clear metrics loading. -- Demo messages, endpoints, query types and the public store API remain unchanged. - -This is client-state integrity. It does not cancel transport or change server -metrics authorization. - -## Evidence and remaining gates - -The initial committed real Pinia/Vitest suite covers seven deferred schedules. A -bounded supplemental runner transpiles and executes the actual store with only -framework/API/session boundaries stubbed: - -- unchanged `main`: 1/7 passed, with only the independent-lane control green; -- initial corrected source: 7/7 passed. - -Review-regression head `f9f6bc9479ec7d211077b545be95a64cf63e65ae` -changed the token-rotation contract from clearing to preserving loaded dashboard -data. Ubuntu passed lint, typecheck, build and PWA validation; its JUnit artifact -ran all seven ownership cases and failed only -`preserves loaded dashboard data while invalidating old-token work on refresh`, -with the current metrics value cleared instead of retaining board `existing`. - -The supplemental runner is not committed and does not replace project -qualification. The current production correction requires exact-head lint, -typecheck, production build, full Vitest on Ubuntu and Windows, complete Required -CI/Extended/Self-Test workflows, and a fresh-context review. No merge, release or -deployment qualification is claimed. +- User identity, authentication or demo-session replacement advances the epoch and clears both data surfaces. +- Token-only rotation preserves settled data, retires old-token UI settlement, and restarts only an active lane whose visible surface is still null. +- Retried metrics/forecast reads retain the exact query captured by the active request. +- Stale requests still resolve or reject to their original callers, but cannot write results, errors, toasts, loading or final state. +- A current failure preserves the previous result and the public error/toast/rejection behavior. +- Metrics and forecast loading remain independent. +- No mutation is replayed and no endpoint, query type or public store API changes. + +This is client-state integrity, not transport cancellation or a server metrics-authorization change. + +## Test-first evidence + +The initial real Pinia suite covered seven deferred schedules. A bounded actual-module runner changed from **1/7 passing on `main`** to **7/7 passing** after the first correction. + +Review-regression head `f9f6bc9479ec7d211077b545be95a64cf63e65ae` isolated loaded-dashboard preservation. The corrected head `8f31b2b72e6941b5e77ab730aea34da8da75e9af` passed Smart CI, Extended and the complete Required CI matrix. + +Issue #3352 then added test-only head `b7425560e7f3c90833dde8bc74d82543d39ff389`, covering a token rotation while both metrics and forecast are still null. A dependency-free runner transpiled and executed the actual production module: + +- before the retry correction: each API was called once and both loading flags became false; +- after the correction: each API was called twice, old-token settlement was suppressed, and fresh-token results populated both lanes. + +Hosted exact-head qualification remains authoritative; the supplemental runner does not replace it. + +## Remaining gates + +Current production correction: `8566adbabd9abe5ddca9a5b09b79a928616db10f` 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 query capture, no retry loops, and no mutation replay. + +No merge, release or deployment qualification is claimed. From 01c82b8691a568506d298fd9142f10cac85f1b42 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 20:36:18 +0100 Subject: [PATCH 09/10] fix(metrics): preserve token-rotation errors --- .../2026-09-21-metrics-read-ownership.md | 4 +-- .../taskdeck-web/src/store/metricsStore.ts | 10 +++--- .../tests/store/metricsStoreOwnership.spec.ts | 35 +++++++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/docs/analysis/2026-09-21-metrics-read-ownership.md b/docs/analysis/2026-09-21-metrics-read-ownership.md index 92e6ebb20..8218c93cc 100644 --- a/docs/analysis/2026-09-21-metrics-read-ownership.md +++ b/docs/analysis/2026-09-21-metrics-read-ownership.md @@ -16,7 +16,7 @@ Review then exposed two token-refresh defects: - Metrics and forecast retain independent latest-request owners. - A newer request retires only the previous owner in the same lane. - User identity, authentication or demo-session replacement advances the epoch and clears both data surfaces. -- Token-only rotation preserves settled data, retires old-token UI settlement, and restarts only an active lane whose visible surface is still null. +- Token-only rotation preserves settled data and errors, retires old-token UI settlement, and restarts only an active lane whose visible surface is still null. - Retried metrics/forecast reads retain the exact query captured by the active request. - Stale requests still resolve or reject to their original callers, but cannot write results, errors, toasts, loading or final state. - A current failure preserves the previous result and the public error/toast/rejection behavior. @@ -40,7 +40,7 @@ Hosted exact-head qualification remains authoritative; the supplemental runner d ## Remaining gates -Current production correction: `8566adbabd9abe5ddca9a5b09b79a928616db10f` before this documentation commit. +Current production correction: `8566adbabd9abe5ddca9a5b09b79a928616db10f` before the current review fix; settled token-rotation errors are now preserved and retry failures are covered. 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 query capture, no retry loops, and no mutation replay. diff --git a/frontend/taskdeck-web/src/store/metricsStore.ts b/frontend/taskdeck-web/src/store/metricsStore.ts index 117ac92e1..35ef19f5b 100644 --- a/frontend/taskdeck-web/src/store/metricsStore.ts +++ b/frontend/taskdeck-web/src/store/metricsStore.ts @@ -72,23 +72,25 @@ export const useMetricsStore = defineStore('metrics', () => { forecastLoading.value = false } - function invalidateRequests(): void { + function invalidateRequests(options: { preserveErrors?: boolean } = {}): void { credentialEpoch += 1 metricsOwner = null forecastOwner = null metricsRetry = null forecastRetry = null loading.value = false - error.value = null forecastLoading.value = false - forecastError.value = null + if (!options.preserveErrors) { + error.value = null + forecastError.value = null + } } function retryEmptyActiveRequests(): void { const pendingMetricsRetry = metricsOwner && metrics.value === null ? metricsRetry : null const pendingForecastRetry = forecastOwner && forecast.value === null ? forecastRetry : null - invalidateRequests() + invalidateRequests({ preserveErrors: true }) if (pendingMetricsRetry) { void pendingMetricsRetry().catch(() => { // The retried store action owns current error/toast state. diff --git a/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts index cdcb784f8..a14bae797 100644 --- a/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts @@ -238,6 +238,41 @@ describe('metricsStore async ownership', () => { expect(toastMocks.error).not.toHaveBeenCalled() }) + it('preserves settled errors when same-user token rotation retires old work', async () => { + vi.mocked(metricsApi.getBoardMetrics).mockRejectedValueOnce(new Error('metrics failure')) + vi.mocked(metricsApi.getBoardForecast).mockRejectedValueOnce(new Error('forecast failure')) + + await expect(store.fetchBoardMetrics({ boardId: 'board-a' })).rejects.toThrow('metrics failure') + await expect(store.fetchBoardForecast({ boardId: 'board-a' })).rejects.toThrow('forecast failure') + expect(store.error).toBe('metrics failure') + expect(store.forecastError).toBe('forecast failure') + + session.token = 'token-b' + + expect(store.error).toBe('metrics failure') + expect(store.forecastError).toBe('forecast failure') + expect(toastMocks.error).toHaveBeenCalledTimes(2) + }) + + it('surfaces a failure from a token-rotation retry', async () => { + const oldMetrics = deferred() + const freshMetrics = deferred() + vi.mocked(metricsApi.getBoardMetrics) + .mockReturnValueOnce(oldMetrics.promise) + .mockReturnValueOnce(freshMetrics.promise) + + const oldRequest = store.fetchBoardMetrics({ boardId: 'board-a' }) + session.token = 'token-b' + oldMetrics.reject(new Error('old-token failure')) + await expect(oldRequest).rejects.toThrow('old-token failure') + + freshMetrics.reject(new Error('fresh-token failure')) + await vi.waitFor(() => { + expect(store.error).toBe('fresh-token failure') + }) + expect(toastMocks.error).toHaveBeenCalledWith('fresh-token failure') + }) + it('retries empty initial metrics and forecast reads after same-user token rotation', async () => { const oldMetrics = deferred() const freshMetrics = deferred() From d323899a8045490b8e08629048fb31cd1b611247 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 20:38:53 +0100 Subject: [PATCH 10/10] test(metrics): cover reverse settlement cleanup --- .../tests/store/metricsStoreOwnership.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts index a14bae797..3a5c08814 100644 --- a/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/metricsStoreOwnership.spec.ts @@ -166,6 +166,25 @@ describe('metricsStore async ownership', () => { expect(store.loading).toBe(false) }) + it('keeps metrics loading cleared when the newer request settles first', async () => { + const older = deferred() + const newer = deferred() + vi.mocked(metricsApi.getBoardMetrics) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise) + + const oldRequest = store.fetchBoardMetrics({ boardId: 'board-old' }) + const newRequest = store.fetchBoardMetrics({ boardId: 'board-new' }) + newer.resolve(metrics('board-new')) + await newRequest + expect(store.loading).toBe(false) + + older.resolve(metrics('board-old')) + await oldRequest + expect(store.metrics?.boardId).toBe('board-new') + expect(store.loading).toBe(false) + }) + it('keeps metrics and forecast lanes independently concurrent', async () => { const pendingMetrics = deferred() const pendingForecast = deferred()