Skip to content
Merged
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
28 changes: 28 additions & 0 deletions docs/analysis/2026-09-21-permission-mutation-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Board-access mutation ordering

Status: stacked draft PR #3335, 2026-09-21. Parent: PR #3330.

## Reproduced defects

`updateAccess` and `revokeAccess` started independently for one access row even though the API accepts no expected revision and the entity has no configured concurrency token. Two role changes could therefore commit or settle in an order that differed from the user's clicks. Update/revoke could also overlap, and a queued pre-logout intent had no transport-time session check.

Independent mutation failures exposed a second ownership gap during review. A queued same-entry mutation cleared the store's shared error when its transport started. If another access row failed while the queued intent was waiting, the queued start erased that unrelated receipt.

## Contract

- One queue exists per `{boardId, accessId}`. Update and revoke for that row run in submission order; different rows remain concurrent.
- The first intent starts transport synchronously. A later intent waits for its predecessor to finish, regardless of success or failure.
- Each queued operation owns a loading token from submission through settlement, so loading does not drop between same-entry operations.
- Immediately before transport, queued work rechecks the initiating session epoch. Identity, token, authentication or demo replacement clears queue registration and prevents old intent from using later credentials.
- A predecessor failure does not cancel the next intent. The queued transport clears an error only when that receipt is owned by its own predecessor; an unrelated concurrent failure remains visible.
- Existing successful-mutation read invalidation, stable-ID grant deduplication and stale settlement rules from #3330 remain unchanged.

The client queue preserves one client's submission order only. It does not solve cross-device concurrency; the backend currently exposes no revision precondition.

## Evidence and remaining gates

The initial ordering negative control ran the actual production store with only framework/API/session boundaries stubbed: one independent-row control passed and four ordering/session schedules failed on the parent, then all five passed after serialization.

Codex review identified the independent-error case. A dedicated deferred Pinia regression was committed before the correction: access row 2 fails while row 1's second update waits, then row 1's queued transport starts without erasing row 2's error.

This child is rebuilt on the parent token-rotation correction rather than retaining its older copy of the permissions store. Exact-head Pinia/Vitest, lint, project typecheck, build, the complete hosted matrix and repeat independent review remain required. After #3330 lands, retarget to current `main`, verify the child-only diff and requalify. No merge, release or deployment qualification is claimed.
41 changes: 41 additions & 0 deletions docs/analysis/2026-09-21-permission-read-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Board-access read and session ownership

Status: draft PR #3330, 2026-09-21. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`.

## Reproduced defects

A board-access read could settle after a confirmed grant, update or revoke and
replace that newer client state. Same-board reads were last-response-wins, one
shared loading Boolean could clear while other boards still loaded, and pending
reads or mutations retained permission to publish after session replacement.

A supplemental runner transpiled and executed the actual store module with only
Pinia/Vue/API/session boundaries stubbed. Seven original schedules failed on
`main`: revoke, update, reverse reads, independent loading, same-user session
replacement, old-session mutation and stale failure. The committed Vitest suite
also covers grant settlement.

## Contract

- One read owner exists per board; unrelated boards remain concurrent.
- A successful mutation advances that board's generation and retires older reads.
- Session identity/auth/demo transitions synchronously advance an epoch, clear
cached access, retire operations and reset loading/error.
- Success, failure, toast and cache writes require the initiating session epoch.
- Loading is derived from current operation tokens, not whichever call settles.
- A stale call still resolves or rejects to its caller; it loses only permission
to alter the replacement session's UI state.

Server authorization remains authoritative. This corrects truthful client cache
behavior and does not claim a server-side authorization bypass. Same-board
mutation serialization is outside this slice.

## Verification and remaining gates

The actual-module supplemental suite changed from 0/7 ownership cases passing on
`main` to 7/7 after the correction; all twelve integration/permission schedules
pass together. TypeScript syntax transpilation passes. Existing permission-store
tests are adjusted so authenticated fixture state is established before the
session-watching store is created. Canonical lint, typecheck, build, complete
Vitest coverage, exact-head hosted CI and independent review remain required.
No merge, release or deployment qualification is claimed here.
271 changes: 219 additions & 52 deletions frontend/taskdeck-web/src/store/permissionsStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { boardAccessApi } from '../api/boardAccessApi'
import { useToastStore } from './toastStore'
import { useSessionStore } from './sessionStore'
Expand All @@ -16,6 +16,146 @@ export const usePermissionsStore = defineStore('permissions', () => {
const loading = ref(false)
const error = ref<string | null>(null)

interface OperationOwner {
epoch: number
token: symbol
}

interface ReadOwner extends OperationOwner {
observedMutationGeneration: number
}

interface MutationTail {
promise: Promise<void>
ownerToken: symbol
}

let sessionEpoch = 0
let errorOwner: symbol | null = null
const activeOperations = new Set<symbol>()
const activeReadByBoard = new Map<string, ReadOwner>()
const mutationGenerationByBoard = new Map<string, number>()
const mutationTails = new Map<string, MutationTail>()

function syncLoading() {
loading.value = activeOperations.size > 0
}

function clearError() {
error.value = null
errorOwner = null
}

function recordError(owner: OperationOwner, message: string) {
error.value = message
errorOwner = owner.token
}

function beginOperation(label: string): OperationOwner {
const owner = { epoch: sessionEpoch, token: Symbol(label) }
activeOperations.add(owner.token)
clearError()
syncLoading()
return owner
}

function ownsSession(owner: OperationOwner): boolean {
return owner.epoch === sessionEpoch
}

function finishOperation(owner: OperationOwner) {
if (!ownsSession(owner)) return
activeOperations.delete(owner.token)
syncLoading()
}

function mutationGeneration(boardId: string): number {
return mutationGenerationByBoard.get(boardId) ?? 0
}

function beginRead(boardId: string): ReadOwner {
const previous = activeReadByBoard.get(boardId)
if (previous?.epoch === sessionEpoch) activeOperations.delete(previous.token)

const operation = beginOperation(`read:${boardId}`)
const owner = {
...operation,
observedMutationGeneration: mutationGeneration(boardId),
}
activeReadByBoard.set(boardId, owner)
return owner
}

function ownsRead(boardId: string, owner: ReadOwner): boolean {
const current = activeReadByBoard.get(boardId)
return ownsSession(owner)
&& current?.token === owner.token
&& owner.observedMutationGeneration === mutationGeneration(boardId)
}

function finishRead(boardId: string, owner: ReadOwner) {
if (activeReadByBoard.get(boardId)?.token === owner.token) {
activeReadByBoard.delete(boardId)
}
finishOperation(owner)
}

function recordMutation(boardId: string) {
mutationGenerationByBoard.set(boardId, mutationGeneration(boardId) + 1)

const staleRead = activeReadByBoard.get(boardId)
if (staleRead?.epoch === sessionEpoch) {
activeReadByBoard.delete(boardId)
activeOperations.delete(staleRead.token)
syncLoading()
}
}

async function enqueueAccessMutation<T>(
boardId: string,
accessId: string,
label: string,
task: (owner: OperationOwner) => Promise<T>,
): Promise<T | undefined> {
const key = `${boardId}:${accessId}`
const predecessor = mutationTails.get(key)
const owner = beginOperation(label)
let release!: () => void
const tail = new Promise<void>((resolve) => { release = resolve })
mutationTails.set(key, { promise: tail, ownerToken: owner.token })

try {
if (predecessor) await predecessor.promise
if (!ownsSession(owner)) return undefined

// Retire only an error produced by this lane's predecessor. Another
// access row can fail while this intent waits and must keep its receipt.
if (predecessor && errorOwner === predecessor.ownerToken) clearError()
return await task(owner)
} finally {
finishOperation(owner)
release()
if (mutationTails.get(key)?.promise === tail) mutationTails.delete(key)
}
}

function resetForSession() {
sessionEpoch += 1
activeOperations.clear()
activeReadByBoard.clear()
mutationGenerationByBoard.clear()
mutationTails.clear()
boardAccess.value = new Map()
loading.value = false
clearError()
}

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

function guardDemoMutation(): never | void {
if (isDemoMode) {
toast.info('This action is view-only in demo mode.')
Expand Down Expand Up @@ -57,91 +197,118 @@ export const usePermissionsStore = defineStore('permissions', () => {
async function fetchBoardAccess(boardId: string) {
if (isDemoMode) {
loading.value = true
error.value = null
clearError()
boardAccess.value.set(boardId, [])
loading.value = false
return
}

const owner = beginRead(boardId)
try {
loading.value = true
error.value = null
const access = await boardAccessApi.getAccess(boardId)
if (!ownsRead(boardId, owner)) return
boardAccess.value.set(boardId, access)
} catch (e: unknown) {
const msg = getErrorDisplay(e, 'Failed to fetch board access').message
error.value = msg
toast.error(msg)
if (ownsRead(boardId, owner)) {
const msg = getErrorDisplay(e, 'Failed to fetch board access').message
recordError(owner, msg)
toast.error(msg)
}
throw e
} finally {
loading.value = false
finishRead(boardId, owner)
}
}

async function grantAccess(boardId: string, dto: GrantAccessDto) {
guardDemoMutation()
const owner = beginOperation(`grant:${boardId}`)
try {
loading.value = true
error.value = null
session.requireUserId('board access management')
const access = await boardAccessApi.grantAccess(boardId, dto)
if (!ownsSession(owner)) return access

recordMutation(boardId)
const existing = boardAccess.value.get(boardId) ?? []
boardAccess.value.set(boardId, [...existing, access])
if (!existing.some(entry => entry.id === access.id)) {
boardAccess.value.set(boardId, [...existing, access])
}
toast.success('Access granted')
return access
} catch (e: unknown) {
const msg = getErrorDisplay(e, 'Failed to grant access').message
error.value = msg
toast.error(msg)
if (ownsSession(owner)) {
const msg = getErrorDisplay(e, 'Failed to grant access').message
recordError(owner, msg)
toast.error(msg)
}
throw e
} finally {
loading.value = false
finishOperation(owner)
}
}

async function updateAccess(boardId: string, accessId: string, dto: UpdateAccessDto) {
guardDemoMutation()
try {
loading.value = true
error.value = null
session.requireUserId('board access management')
const updated = await boardAccessApi.updateAccess(boardId, accessId, dto)
const existing = boardAccess.value.get(boardId) ?? []
const index = existing.findIndex(a => a.id === accessId)
if (index !== -1) {
existing[index] = updated
boardAccess.value.set(boardId, [...existing])
}
toast.success('Access updated')
return updated
} catch (e: unknown) {
const msg = getErrorDisplay(e, 'Failed to update access').message
error.value = msg
toast.error(msg)
throw e
} finally {
loading.value = false
}
return await enqueueAccessMutation(
boardId,
accessId,
`update:${boardId}:${accessId}`,
async (owner) => {
try {
session.requireUserId('board access management')
const updated = await boardAccessApi.updateAccess(boardId, accessId, dto)
if (!ownsSession(owner)) return updated

recordMutation(boardId)
const existing = boardAccess.value.get(boardId) ?? []
if (existing.some(access => access.id === accessId)) {
boardAccess.value.set(
boardId,
existing.map(access => access.id === accessId ? updated : access),
)
}
toast.success('Access updated')
return updated
} catch (e: unknown) {
if (ownsSession(owner)) {
const msg = getErrorDisplay(e, 'Failed to update access').message
recordError(owner, msg)
toast.error(msg)
}
throw e
}
},
)
}

async function revokeAccess(boardId: string, accessId: string) {
guardDemoMutation()
try {
loading.value = true
error.value = null
session.requireUserId('board access management')
await boardAccessApi.revokeAccess(boardId, accessId)
const existing = boardAccess.value.get(boardId) ?? []
boardAccess.value.set(boardId, existing.filter(a => a.id !== accessId))
toast.success('Access revoked')
} catch (e: unknown) {
const msg = getErrorDisplay(e, 'Failed to revoke access').message
error.value = msg
toast.error(msg)
throw e
} finally {
loading.value = false
}
await enqueueAccessMutation(
boardId,
accessId,
`revoke:${boardId}:${accessId}`,
async (owner) => {
try {
session.requireUserId('board access management')
await boardAccessApi.revokeAccess(boardId, accessId)
if (!ownsSession(owner)) return

recordMutation(boardId)
const existing = boardAccess.value.get(boardId) ?? []
boardAccess.value.set(boardId, existing.filter(access => access.id !== accessId))
toast.success('Access revoked')
} catch (e: unknown) {
if (ownsSession(owner)) {
const msg = getErrorDisplay(e, 'Failed to revoke access').message
recordError(owner, msg)
toast.error(msg)
}
throw e
}
},
)
}

return {
boardAccess,
loading,
Expand Down
Loading
Loading