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
7 changes: 7 additions & 0 deletions OUTSTANDING_TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@ The maintainer directed (2026-08-30) that the repository goes **private for the

### J.2. Additional control-plane review checkpoint (2026-09-08)

PR [#3358](https://github.com/Chris0Jeky/Taskdeck/pull/3358) also needs maintainer review before
merge: its logout fix moves the board-delete expression used by mutation smoke testing, requiring
the source range and the companion assertion in `scripts/ci/smart-ci/mutation-smoke-contract.test.mjs`
to move from line 667 to 673. The expression, column bounds and negative checks are unchanged.
This small test-data change still touches a declared control path; independent review and passing
CI do not supply the maintainer decision under ADR-0066.

- [ ] **Review PR #2787 post hoc and review new CI-control candidates before merge.** The coordinator merged prompt-v3 PR #2787 at `0cebd938d79f045a20ce99bff495b986c25cf267` with hosted checks and independent Terra review, but without the maintainer review required by the ADR-0066 amendment. Its changed surface includes the Windows archive acceptance script and matching tests. The earlier SC-10 delegation covered twelve named PRs and did not include #2787. Please review that merged change; the coordinator has not inferred acknowledgement or reverted it. New nightly observation PR #2791 and the #2335 control-trust test PR must finish independent review and exact-head hosted qualification before the maintainer reviews their final heads. This checkpoint grants no release, repository-settings, or selective-execution approval.

### J.3. Twelve control-plane PRs merged outside the ADR-0066 per-PR review (2026-09-09 to 2026-09-10)
Expand Down
22 changes: 21 additions & 1 deletion docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

Last Updated: 2026-09-22

## Logout retires board mutation settlements (#3306)

Board, card (including archive/restore), label and comment operations capture the shared
session generation before transport. Logout advances it before clearing state. Old responses
still settle for their original callers but cannot repopulate caches, change counts or board
selection, invalidate new-session detail reads, publish toasts/errors, or clear new loading.
Queued label writes stop before transport across logout, including when no board detail was
loaded, and stale writes cannot initiate recovery reads under the next session. Direct card,
label, comment and provenance reads honor the same client boundary. Same-session programmatic
writes before detail loads continue to work. Deferred real-store regressions cover these cases.
Board creation callers also capture that session: board-list navigation and workspace setup
stop on retirement, including between starter-pack catalog/apply awaits. Stale continuations
cannot route the next account, start its template requests, clear its summaries or publish setup
notifications. Store results and errors still settle for the original caller.

This prevents old-account data from reappearing and reduces cleanup after account switching.
It preserves review-first proposal behavior and does not undo server writes. Same-session
loading arbitration remains #3305; card/comment ordering PRs #3312/#3304 require separate
reconciliation with these guards before integration.

## Column writes follow their board visit (#3314)

Create, update, delete and reorder share one mutation lane per board, preserving intent order
Expand All @@ -16,7 +36,7 @@ for that recovery and keeps its later result.

This reduces navigation-induced board maintenance while preserving existing review-first
proposal behavior. It does not cancel a write already accepted by the server. Shared ownership
for card, comment, label and board mutations remains tracked in #3306; shared loading arbitration
for card, comment, label and board mutations is covered by #3306 above; shared loading arbitration
remains tracked in #3305. Deferred-response store tests and BoardView lifecycle tests cover the
route/session boundary, and the existing three ordering assertions now compare actual reactive
array identities as well as full contents.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,13 @@ function closeModal() {
emit('close')
}

async function applyStarterPack(boardId: string, starterPackId: string): Promise<void> {
async function applyStarterPack(
boardId: string,
starterPackId: string,
isCurrentSession: () => boolean,
): Promise<void> {
const catalog = await starterPacksApi.getCatalog(boardId)
if (!isCurrentSession()) return
const selectedPack = catalog.find((entry) => entry.id === starterPackId)
if (!selectedPack) {
throw new Error('The selected starter pack is no longer available.')
Expand All @@ -59,6 +64,7 @@ async function applyStarterPack(boardId: string, starterPackId: string): Promise
manifest: selectedPack.manifest,
dryRun: false,
})
if (!isCurrentSession()) return

if (result.hasBlockingConflicts || !result.applied) {
throw new Error('The starter pack could not be applied to the new board.')
Expand Down Expand Up @@ -88,21 +94,25 @@ async function submitSetup() {
return
}

const isCurrentSession = boardStore.captureSession()
submitting.value = true
setupError.value = null
const nextBoardName = boardName.value.trim()

try {
const board = await boardStore.createBoard({ name: nextBoardName })
if (!isCurrentSession()) return

if (selectedSetup.value.starterPackId) {
try {
await applyStarterPack(board.id, selectedSetup.value.starterPackId)
await applyStarterPack(board.id, selectedSetup.value.starterPackId, isCurrentSession)
} catch (error: unknown) {
if (!isCurrentSession()) return
const message = getErrorMessage(error, 'Board created, but the starter pack could not be applied')
toast.warning(`${message}. You can still finish setup from the board view.`)
}
}
if (!isCurrentSession()) return

workspace.clearHomeSummary()
workspace.clearTodaySummary()
Expand All @@ -111,9 +121,9 @@ async function submitSetup() {
void router.push(`/workspace/boards/${board.id}`)
resetState()
} catch (error: unknown) {
setupError.value = getErrorMessage(error, 'Failed to create the board')
if (isCurrentSession()) setupError.value = getErrorMessage(error, 'Failed to create the board')
} finally {
submitting.value = false
if (isCurrentSession()) submitting.value = false
}
}

Expand Down
31 changes: 19 additions & 12 deletions frontend/taskdeck-web/src/store/board/boardCrudStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { buildDemoBoardList } from '../../utils/demoData'
import { applyBoardCardCounts } from '../../utils/boardCardCounts'
import type { CreateBoardDto, UpdateBoardDto } from '../../types/board'
import { initialCardFilters, type BoardState } from './boardState'
import type { BoardHelpers } from './boardStoreHelpers'
import { captureBoardSession, type BoardHelpers } from './boardStoreHelpers'

// Minimum gap between board-list fetches. Multiple views (BoardsListView,
// ActivityView, ReviewView, etc.) can call fetchBoards on mount in quick
Expand Down Expand Up @@ -594,27 +594,31 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)

async function createBoard(board: CreateBoardDto) {
helpers.guardDemoMutation()
const isCurrentSession = captureBoardSession(state)
try {
state.loading.value = true
state.error.value = null
const newBoard = await boardsApi.createBoard(board)
if (!isCurrentSession()) return newBoard
Comment thread
Chris0Jeky marked this conversation as resolved.
state.boards.value.push(newBoard)
helpers.toast.success(`Board "${newBoard.name}" created successfully`)
return newBoard
} catch (e: unknown) {
helpers.handleApiError(e, 'Failed to create board')
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to create board')
throw e
} finally {
state.loading.value = false
if (isCurrentSession()) state.loading.value = false
}
}

async function updateBoard(boardId: string, board: UpdateBoardDto) {
helpers.guardDemoMutation()
const isCurrentSession = captureBoardSession(state)
try {
state.loading.value = true
state.error.value = null
const updatedBoard = await boardsApi.updateBoard(boardId, board)
if (!isCurrentSession()) return updatedBoard
// Board settings are part of the board-detail fan-out, so a detail read
// that captured the pre-save state must not replace this update (#2435).
helpers.markBoardDetailMutation(boardId)
Expand All @@ -633,19 +637,21 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
helpers.toast.success('Board updated successfully')
return updatedBoard
} catch (e: unknown) {
helpers.handleApiError(e, 'Failed to update board')
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to update board')
throw e
} finally {
state.loading.value = false
if (isCurrentSession()) state.loading.value = false
}
}

async function deleteBoard(boardId: string) {
helpers.guardDemoMutation()
const isCurrentSession = captureBoardSession(state)
try {
state.loading.value = true
state.error.value = null
await boardsApi.deleteBoard(boardId)
if (!isCurrentSession()) return

// Clear detailed state for the current board before removing it from the
// main boards list. This prevents any watchers on the `boards` array
Expand Down Expand Up @@ -673,10 +679,10 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)

helpers.toast.success('Board archived successfully')
} catch (e: unknown) {
helpers.handleApiError(e, 'Failed to archive board')
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to archive board')
throw e
} finally {
state.loading.value = false
if (isCurrentSession()) state.loading.value = false
}
}

Expand All @@ -691,8 +697,9 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
* without ending the session, and clearing on them would drop the board the
* user is looking at.
*
* Two generations are bumped rather than one because the list and the detail
* read are separate lifecycles. A bumped generation is what makes an
* The shared session generation retires mutations and direct cache reads,
* including requests begun before any board detail loaded. List and detail
* reads additionally retain their separate request generations. A bump makes an
* already-issued request safe: the response still arrives, finds its
* generation stale, and returns without writing board state, the loading
* flag, a throttle stamp, or an error surface. The loading flag is in that
Expand All @@ -702,9 +709,9 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
* in flight during the reset would land afterwards and repopulate the store
* with the previous account's boards.
*
* The generation bump makes a late response harmless; the abort keeps it from
* being sent at all, so no request outlives the session that started it. Both
* lifecycles are aborted: every open list read and the active detail read.
* The generation bump suppresses late client effects. Both read lifecycles
* also receive cancellation: every open list read and the active detail read.
* Submitted writes can still complete on the server; reset does not undo them.
*/
function resetForLogout() {
state.boardMutationSessionGeneration.value++
Expand Down
6 changes: 6 additions & 0 deletions frontend/taskdeck-web/src/store/board/boardStoreHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import type { BoardState } from './boardState'
// thing to a reader, so both map to the same copy.
const TIMEOUT_CODES = new Set(['ECONNABORTED', 'ETIMEDOUT'])

/** A null board can mean pre-load or logout; only the reset generation distinguishes them. */
export function captureBoardSession(state: BoardState): () => boolean {
const generation = state.boardMutationSessionGeneration.value
return () => state.boardMutationSessionGeneration.value === generation
}

/**
* Whether this failure is a client-side timeout — a routine outcome on every
* board read since #2685 bounded them (`timeout: BOARD_REQUEST_TIMEOUT_MS`,
Expand Down
24 changes: 16 additions & 8 deletions frontend/taskdeck-web/src/store/board/cardCommentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { cardCommentsApi } from '../../api/cardCommentsApi'
import type { CardComment, CreateCardCommentDto, UpdateCardCommentDto } from '../../types/comments'
import type { BoardState } from './boardState'
import type { BoardHelpers } from './boardStoreHelpers'
import { captureBoardSession, type BoardHelpers } from './boardStoreHelpers'

export function createCardCommentActions(state: BoardState, helpers: BoardHelpers) {
function getCardComments(cardId: string): CardComment[] {
Expand All @@ -13,25 +13,29 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper

async function fetchCardComments(boardId: string, cardId: string) {
if (helpers.isDemoMode) return []
const isCurrentSession = captureBoardSession(state)
try {
const comments = await cardCommentsApi.getComments(boardId, cardId)
if (!isCurrentSession()) return comments
state.cardCommentsByCardId.value = {
...state.cardCommentsByCardId.value,
[cardId]: comments,
}
return comments
} catch (e: unknown) {
helpers.handleApiError(e, 'Failed to fetch card comments')
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to fetch card comments')
throw e
}
}

async function createCardComment(boardId: string, cardId: string, dto: CreateCardCommentDto) {
helpers.guardDemoMutation()
const isCurrentSession = captureBoardSession(state)
try {
state.loading.value = true
state.error.value = null
const createdComment = await cardCommentsApi.createComment(boardId, cardId, dto)
if (!isCurrentSession()) return createdComment
const existingComments = state.cardCommentsByCardId.value[cardId] ?? []
state.cardCommentsByCardId.value = {
...state.cardCommentsByCardId.value,
Expand All @@ -44,10 +48,10 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper
helpers.toast.success('Comment added')
return createdComment
} catch (e: unknown) {
helpers.handleApiError(e, 'Failed to create card comment')
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to create card comment')
throw e
} finally {
state.loading.value = false
if (isCurrentSession()) state.loading.value = false
}
}

Expand All @@ -58,10 +62,12 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper
dto: UpdateCardCommentDto,
) {
helpers.guardDemoMutation()
const isCurrentSession = captureBoardSession(state)
try {
state.loading.value = true
state.error.value = null
const updatedComment = await cardCommentsApi.updateComment(boardId, cardId, commentId, dto)
if (!isCurrentSession()) return updatedComment
const existingComments = state.cardCommentsByCardId.value[cardId] ?? []
state.cardCommentsByCardId.value = {
...state.cardCommentsByCardId.value,
Expand All @@ -73,30 +79,32 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper
helpers.toast.success('Comment updated')
return updatedComment
} catch (e: unknown) {
helpers.handleApiError(e, 'Failed to update card comment')
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to update card comment')
throw e
} finally {
state.loading.value = false
if (isCurrentSession()) state.loading.value = false
}
}

async function deleteCardComment(boardId: string, cardId: string, commentId: string) {
helpers.guardDemoMutation()
const isCurrentSession = captureBoardSession(state)
try {
state.loading.value = true
state.error.value = null
await cardCommentsApi.deleteComment(boardId, cardId, commentId)
if (!isCurrentSession()) return
const existingComments = state.cardCommentsByCardId.value[cardId] ?? []
state.cardCommentsByCardId.value = {
...state.cardCommentsByCardId.value,
[cardId]: existingComments.filter((comment) => comment.id !== commentId),
}
helpers.toast.success('Comment deleted')
} catch (e: unknown) {
helpers.handleApiError(e, 'Failed to delete card comment')
if (isCurrentSession()) helpers.handleApiError(e, 'Failed to delete card comment')
throw e
} finally {
state.loading.value = false
if (isCurrentSession()) state.loading.value = false
}
}

Expand Down
Loading
Loading