Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/analysis/2026-09-21-audit-history-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Activity history request ownership

Status: corrective draft for #3344 / PR #3345. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`.

## 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 route changes could restore older queries, report obsolete failures or clear current loading.

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.
- 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 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.

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

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 loaded-history preservation cases.

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:

- 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

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.
209 changes: 162 additions & 47 deletions frontend/taskdeck-web/src/store/auditStore.ts
Original file line number Diff line number Diff line change
@@ -1,89 +1,204 @@
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<AuditEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)

type ReadRetry = () => Promise<void>

interface ReadOwner {
epoch: number
token: symbol
successorReady: Promise<{ successor: Promise<void> }>
resolveSuccessor: (successor: Promise<void>) => void
}

let credentialEpoch = 0
let currentRead: ReadOwner | null = null
let currentRetry: ReadRetry | null = null
const retiredReads = new Set<ReadOwner>()

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(retry: ReadRetry): ReadOwner {
if (currentRead) retiredReads.add(currentRead)
let resolveSuccessor!: (successor: Promise<void>) => void
const successorReady = new Promise<{ successor: Promise<void> }>((resolve) => {
resolveSuccessor = (successor) => resolve({ successor })
})
const owner = {
epoch: credentialEpoch,
token: Symbol('audit-history'),
successorReady,
resolveSuccessor,
}
currentRead = owner

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Settle superseded owners before overwriting them

When query B starts while query A is still pending, this assignment discards the only reference capable of resolving A's successorReady. If a token rotation or session reset then advances credentialEpoch, only B is signalled; when A's transport later settles, A enters awaitSuccessor() and waits forever on its unresolved promise. Thus the public action for A never resolves or rejects in this overlap-plus-credential-change scenario, contrary to the documented stale-work contract; retire the previous owner when replacing it or retain all owners that may need signalling.

Useful? React with 👍 / 👎.

currentRetry = retry
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 {
retiredReads.delete(owner)
if (!ownsRead(owner)) return
currentRead = null
currentRetry = null
loading.value = false
}

function invalidateCurrentRead(): void {
for (const owner of retiredReads) owner.resolveSuccessor(Promise.resolve())
retiredReads.clear()
credentialEpoch += 1
currentRead = null
currentRetry = null
loading.value = false
error.value = null
}

async function awaitSuccessor(owner: ReadOwner): Promise<boolean> {
if (owner.epoch === credentialEpoch) return false
await owner.successorReady.then(({ successor }) => successor)
return true
}

function retryActiveRead(): void {
const retry = currentRead && currentRetry
? { owner: currentRead, retry: currentRetry }
: null
invalidateCurrentRead()
if (!retry) return

let resolveSuccessor!: () => void
let rejectSuccessor!: (reason: unknown) => void
const successor = new Promise<void>((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()
retiredRead?.resolveSuccessor(Promise.resolve())
entries.value = []
}

watch(
() => [session.userId, session.isAuthenticated, session.isDemo],
resetForSession,
{ flush: 'sync' },
)

watch(
() => session.token,
retryActiveRead,
{ flush: 'sync' },
)

async function fetchHistory(
request: () => Promise<AuditEntry[]>,
fallbackMessage: string,
retry: ReadRetry,
): Promise<void> {
const owner = beginRead(retry)
try {
loading.value = true
error.value = null
entries.value = await auditApi.getBoardHistory(boardId, clampLimit(limit))
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
}
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)
} else if (await awaitSuccessor(owner)) {
return
}
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
resetForSession()
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',
() => fetchBoardHistory(boardId, limit),
)
}

async function fetchEntityHistory(entityType: string, entityId: string, limit = 50) {
if (isDemoMode) {
resetForSession()
return
}

await fetchHistory(
() => auditApi.getEntityHistory(entityType, entityId, clampLimit(limit)),
'Failed to fetch entity history',
() => fetchEntityHistory(entityType, entityId, limit),
)
}

async function fetchUserHistory(limit = 50) {
if (isDemoMode) {
loading.value = true
error.value = null
entries.value = []
loading.value = false
resetForSession()
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',
() => fetchUserHistory(limit),
)
}

return {
entries,
loading,
Expand Down
Loading
Loading