Skip to content

fix(board): complete board-detail arbitration after local writes and keep a current 403 authoritative - #2603

Merged
Chris0Jeky merged 3 commits into
mainfrom
issue-2435/board-403-authority
Sep 5, 2026
Merged

fix(board): complete board-detail arbitration after local writes and keep a current 403 authoritative#2603
Chris0Jeky merged 3 commits into
mainfrom
issue-2435/board-403-authority

Conversation

@Chris0Jeky

@Chris0Jeky Chris0Jeky commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Summary

PR #2433 arbitrated board-detail reads by request generation, cancellation and a per-board data
mutation epoch. Two paths were left incomplete and this PR closes them, plus the authorization
residual recorded on 2026-09-03.

Arbitration before this change. startBoardFetch stamps every board-detail fan-out with a
monotonic request generation and snapshots the board's mutation epoch. A payload commits only when
both still hold (isCommitEligible). Card and column writes advanced the epoch on success. Board
settings writes and label writes did not, so a fan-out that captured the pre-save board or label set
could still commit over a completed save, which is the Retry-overlaps-a-save case in the review
thread. A background read invalidated by the epoch simply returned false, so the server-side sibling
reordering that a local card move triggers was never re-read until another realtime event arrived.
On the failure path a single guard, !isCommitEligible() || axios.isCancel(e), gated every outcome,
so a background 403 was swallowed whenever a local mutation had advanced the epoch while that read
was open.

Arbitration after this change. Board settings and label writes advance the epoch on success, so the
whole board-detail payload is covered by one arbitration key. When a background read's payload is
dropped because the epoch advanced under it, and the read is still the current generation, it queues
exactly one successor background read through the existing single-slot queue, which now drains after
any read settles rather than only after an explicit one. Repeated invalidating events during that
window produce exactly one successor, so at most one detail fan-out is ever in flight and the
successor chain ends when mutations stop. On the failure path the gates are now split: generation
currency and cancellation decide whether a failure is reported at all, a background 403 is then
surfaced as the access-revoked outcome regardless of the epoch, and the epoch gate still applies to
payload commits and to explicit failures at every status, 403 included, because the view that started
an explicit read owns its outcome and its error surface (#2434). #2435 scopes the authorization
carve-out to the background 403 only, so an explicit Retry that 403s after a local write advanced the
epoch returns false silently, exactly as it did at the merge base.

Explicit reads deliberately do not queue a successor. Their outcome is owned by the view that
started them, and the view-local error surface is tracked separately by #2434, which this PR does not
touch. #1736's rule that an explicit Retry stays authoritative over realtime refresh is unchanged:
fetchBoard still settles a queued background read when an explicit intent starts.

Closes #2435

Changes

  • frontend/taskdeck-web/src/store/board/labelStore.ts — successful createLabel, updateLabel and
    deleteLabel advance the board-detail mutation epoch.
  • frontend/taskdeck-web/src/store/board/boardCrudStore.ts — successful updateBoard advances the
    epoch; a background read invalidated by the epoch queues one successor read; the queue
    drains after any read settles; the failure path separates authorization freshness (generation and
    cancellation only) from payload commit eligibility (generation, cancellation and epoch).
  • Specs: src/tests/store/board/labelStore.spec.ts, src/tests/store/board/boardCrudStore.spec.ts,
    src/tests/store/boardStore.spec.ts.

No view, composable, router, locale or backend file is touched. Board realtime stays per-board.

Test plan

Verified, from frontend/taskdeck-web after npm ci:

  • npx vitest --run --maxWorkers=2 src/tests/store src/tests/views/BoardView.spec.ts src/tests/views/paper/PaperBoardView.spec.ts src/tests/views/paper/PaperBoardManagement.spec.ts src/tests/composables/useBoardRealtime.spec.ts src/tests/resilience — 55 files, 1011 tests passed.
  • npx vitest --run --maxWorkers=2 src/tests/views/BoardView.coverage.spec.ts src/tests/views/BoardView.keyboardRouting.spec.ts src/tests/views/BoardsListView.spec.ts src/tests/views/BoardAccessView.spec.ts src/tests/views/paper/boardMutationCapabilityParity.spec.ts src/tests/property/storeResilience.spec.ts src/tests/composables/useActivityQuery.spec.ts src/tests/composables/useStarterPackCatalog.spec.ts src/tests/composables/useStarterPackImport.spec.ts src/tests/guards/primaryActionGuards.spec.ts src/tests/router/workspaceRouteStability.spec.ts src/tests/components/StarterPackCatalogModal.spec.ts src/tests/api/http.spec.ts — 13 files, 253 tests passed.
  • npm run typecheck — clean.
  • npx eslint src/store/board/boardCrudStore.ts src/store/board/labelStore.ts src/tests/store/board/boardCrudStore.spec.ts src/tests/store/board/labelStore.spec.ts src/tests/store/boardStore.spec.ts — clean.
  • npm run build — succeeded.
  • git diff --check — clean.

New regressions are deterministic: deferred promises and explicit epoch bumps, no fake or wall-clock
timers. Red first, confirmed against the unmodified source: the three label-epoch specs, the
updateBoard epoch spec, the successor-read spec, the successor-bound spec, the route-change
discard spec, both 403-after-mutation ordering specs, and both facade-level specs
(rejects a delayed fan-out after a successful board settings write commits locally and
... after a successful label write commits locally, re-run red with the two epoch calls stubbed
out, then restored). The unmount-discard spec was green before the change through a different code
path and is a guard rather than a red-first regression; the explicit-retry spec is a
characterization of behaviour #2433 already had.

Coverage by acceptance point: board settings and label writes advance the epoch (unit plus facade
specs); a background read invalidated by a local mutation queues one successor that commits the
repaired ordering; three invalidating events during the window produce exactly one successor and
never a parallel fan-out; a route load and an unmount both discard the queued successor; a current
background 403 stays authoritative in both mutation-then-rejection and rejection-then-mutation
orderings, and a failed read queues no successor.

NOT verified: Playwright end-to-end (no running stack); no manual browser session; backend untouched
and not run; the full vitest --run suite was not run in one pass (it OOMs on this box), only the
targeted board-related files listed above.

Boundaries and risks

  • Keep explicit board retry authoritative over realtime refresh #1736 is not reopened: explicit retry authority over realtime refresh is unchanged.
  • Clear stale board-load errors after background recovery #2434 is not touched: the stale view-local boardLoadError is out of scope, which is why an
    invalidated explicit read still queues nothing.
  • Realtime stays per-board; useBoardRealtime.ts is unchanged. Its own refreshInFlight guard plus
    the store returning the active read to a matching background request keeps the in-flight count at
    one.
  • Risk: label and board-settings writes now invalidate an in-flight detail read that would previously
    have committed. That is the intended fix, and for background reads the queued successor re-reads
    immediately; for an explicit read the local state is kept and the user's own write is preserved.
  • Risk: the successor chain is driven by mutations only. Failed reads queue nothing, so a persistent
    server failure cannot produce a retry loop.
  • Worktree .worktrees/codex-2435-board-403-authority holds only gitignored frontend/taskdeck-web/node_modules/
    and frontend/taskdeck-web/dist/, both regenerable by npm ci and npm run build. Nothing was
    copied out; the tree is ready for plain git worktree remove.

Round 2

Review verdict was SHIP with no CRITICAL or HIGH. One batch of comment and test corrections, no
behaviour change. Head 9adf800fd24c612931714f7cab932c848ccf9385.

  • MEDIUM, comment only. The explicit failure gate in boardCrudStore.ts is reached with only
    generation currency, non-cancellation and explicit intent still true, so it covers every status
    including 403, not just non-authorization failures as the comment claimed. The comment now says
    explicit reads deliberately stay epoch-gated at every status because the view owns their outcome
    and error surface (Clear stale board-load errors after background recovery #2434), and that Complete board detail arbitration after local writes #2435 scopes the carve-out to the background 403. The Summary
    sentence above is corrected the same way. An explicit Retry that 403s after a local write advanced
    the epoch returns false silently, unchanged from the merge base.
  • LOW, tests. The bound spec exercised the join branch rather than queue coalescing: the two extra
    background requests return the same in-flight promise, and queueSuccessorForInvalidatedRead is
    reachable once per read. It is retitled to what it proves, "produces exactly one successor read and
    no parallel fan-out for repeated mutation events during one background read", its inline assertions
    name the two mechanisms, and the rationale comment in boardCrudStore.ts names them too.
  • LOW, tests. Added the failure-path negatives for updateLabel and deleteLabel asserting the epoch
    does not advance when the write fails, matching the one createLabel already had.
  • Not fixed, tracked on Clear stale board-load errors after background recovery #2434 by the coordinator: a successor started by the drain commits fresh data
    without passing through BoardView's realtime wrapper, so a stale refresh banner stays until an
    explicit Retry.

Verified at this head, from frontend/taskdeck-web:

  • npx vitest --run --maxWorkers=2 src/tests/store src/tests/views/BoardView.spec.ts src/tests/views/paper/PaperBoardView.spec.ts src/tests/views/paper/PaperBoardManagement.spec.ts src/tests/composables/useBoardRealtime.spec.ts src/tests/resilience — 55 files, 1013 tests passed (1011 before, plus the two new label negatives).
  • The same 13 neighbouring spec files as round 1 — 13 files, 253 tests passed.
  • npm run typecheck clean; npx eslint over the same five changed files clean; git diff --check clean.

NOT verified in round 2: npm run build was not re-run (round 1 build passed and this round changes
only comments and specs); Playwright still not run.

Labels are part of the board-detail fan-out, so a read that captured the pre-write label set could replace a completed local label write when it resolved after it. Successful create, update and delete now advance the epoch the fan-out is arbitrated against.
Board settings writes now advance the board-detail mutation epoch, so a
detail fan-out that captured the pre-save board cannot replace the saved
update when it resolves after the write.

A background read whose payload is dropped because a local mutation
completed mid-flight now queues one successor read. The initiating client
receives its own realtime event before the mutation response, so a card
move could invalidate its own refresh and leave server-side sibling
reordering unrepaired until another event arrived. The queue is
single-slot, so repeated invalidations coalesce into that one read and no
parallel fan-out is opened.

A background HTTP 403 that is current by request generation and was not
cancelled now stays authoritative even when a local mutation advanced the
data epoch while the read was open. Authorization freshness is a separate
rule from board-payload commits: the epoch only says a payload is too old
to install, not that the reader still has access. Payload commits and
non-authorization explicit failures stay epoch-gated.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Review gate (Codex credits exhausted, SC-9): one fresh-context adversarial reviewer on head e7594d5 (merge base 2803355). Verdict: SHIP. Confirmed: every epoch bump (updateBoard, the three label writes, and the existing card and column writes) sits immediately after the API await and before the synchronous local patch with no await between, so a failed write cannot bump and no older fan-out can commit between the patch and the bump; no optimistic local writes exist in the card, column or label stores, so a successor payload can never revert an unacknowledged edit; the successor snapshots the epoch after the invalidating bump and terminates, chains are strictly serial and never loop on errors; isCurrentGeneration implies ownership of the active-fetch slot, so the queue-then-drain handoff cannot start a successor for a superseded read; route change settles the queue then aborts and bumps, unmount settles the slot before nulling the active fetch, and the reverse interleaving is caught by the generation guard; the 403 matrix holds (background 403 after a moved epoch surfaces as the board mutation error; stale-by-generation and cancelled are suppressed; an older background 403 cannot clobber a newer explicit success); #1736's explicit-over-realtime authority is intact; both skins go through BoardView's single arbitration; once-queues are fully consumed per test; facade specs assert through useBoardStore().

Findings by bin:

  • Fixed in the same round (comment and test precision, no behaviour change): MEDIUM, the comment claimed non-authorization explicit failures stay epoch-gated while every explicit failure including 403 does (unchanged from the base and within Complete board detail arbitration after local writes #2435's background-only scope); the comment and PR body now say explicit reads stay epoch-gated at every status because the view owns their outcome. LOW, the coalescing spec exercises the join branch rather than queue coalescing; retitled to what it proves, the rationale comment names the real mechanism, and updateLabel / deleteLabel gain the failure-path negatives createLabel already had.
  • Tracked on Clear stale board-load errors after background recovery #2434: a drain-started successor commits fresh data without reaching BoardView's realtime wrapper, so a stale refresh banner can stay until an explicit Retry (not a regression; slightly widens Clear stale board-load errors after background recovery #2434's visible window).
  • Declined: none.

Evidence at the reviewed head (implementation run): 55 files / 1011 targeted plus 13 further board specs / 253, typecheck, ESLint, build, diff check; red-first for the label-epoch, updateBoard epoch, successor, bound, route-discard, both 403-ordering and both facade specs. Not verified: Playwright, manual browser session. Merge after the fix commit ages and ci-required is green at the new head.

…egatives

Review round 1, no behaviour change.

The explicit failure gate is reached with only generation currency,
non-cancellation and explicit intent still true, so it covers every status
including 403, not only non-authorization failures. Say so, and record why:
the view owns an explicit read's outcome and error surface (#2434), while
the authorization carve-out above is scoped by #2435 to the background 403.

Name the real mechanism behind the successor bound. Repeated events cannot
open a parallel read because a background request arriving during an open
read joins its promise, and the successor check runs at most once per read.
The retitled spec now claims what it proves rather than queue coalescing.

Add the missing failure-path negatives for updateLabel and deleteLabel,
matching the one createLabel already had.
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Round 2 (head 9adf800): comment and spec text only, no behaviour change, verified by coordinator read of the worker's report. The explicit-failure gate comment now says explicit reads stay epoch-gated at every status, 403 included, because the view that started the read owns its outcome (#2434), with #2435's authorization carve-out scoped to the background 403; the PR body says the same and states that an explicit Retry that 403s after a local write advanced the epoch returns false silently, unchanged from the merge base. The bound spec is retitled to what it proves (one successor read and no parallel fan-out for repeated mutation events during one background read) with its assertions naming the join branch and the once-per-read check, the rationale comment above queueSuccessorForInvalidatedRead names those as the mechanism, and updateLabel / deleteLabel gain the failure-path negatives createLabel already had. Verified at this head: 55 files / 1013 (1011 at round 1), 13 neighbours / 253, typecheck, scoped ESLint, diff check; npm run build stands at round 1. Round count: 2. Merge after ci-required is green at 9adf800 and the head has aged.

@Chris0Jeky
Chris0Jeky merged commit 6473767 into main Sep 5, 2026
37 checks passed
@github-project-automation github-project-automation Bot moved this from Pending to Done in Taskdeck Execution Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Complete board detail arbitration after local writes

1 participant