fix(cards): reconcile work-item type editing with older board payloads - #3026
Conversation
A board payload cached before the optional canWrite field existed omits it, and the Board contract treats only an explicit false as read-only. The type selector gated on canWrite === true, so that silence disabled the control for an otherwise authorized writer with no way forward. useCardTypePermission reads the board back from the server when the loaded payload does not state the permission, and gates on that answer. A failed read leaves the state unknown and the editor offers an explicit Refresh permission control instead of a silently disabled selector. Viewer, archived board and archived card stay read-only and cost no request, and no permission is ever derived client-side.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8d9cb1cf90
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| * field says. | ||
| */ | ||
| confirmed.value = { boardId, canWrite: board.canWrite !== false && board.isArchived !== true } | ||
| } catch { |
There was a problem hiding this comment.
Handle 403 as a permission denial
When GET /boards/{id} returns 403—for example, because access was revoked after the cached board loaded—this catch-all converts the definitive denial into permissionUnknown, so the editor misleadingly says the payload omitted permission and offers a futile Refresh loop. Preserve generic recovery for network/5xx failures, but surface 403 as a permission-denied state.
AGENTS.md reference: frontend/AGENTS.md:L6-L6
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Triaged once: real, non-blocking (nothing is granted either way; a 403 lands in the unknown state with a recovery control). Tracked as #3030 together with the focus-trap MEDIUM.
| checking.value = true | ||
| failedBoardId.value = null | ||
| try { | ||
| const board = await boardsApi.getBoard(boardId) |
There was a problem hiding this comment.
Skip the permission probe in backend-less demo mode
In backend-less demo mode, buildDemoBoardDetail omits canWrite, so opening any demo card reaches this call even though demo mode explicitly has no server; the shared HTTP client then targets its localhost fallback, retries/logs a network failure, and leaves the demo showing irrelevant permission-recovery UI. Handle demo mode locally or stamp the demo board's read-only capability instead of issuing this request.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed at head 653fc95: demo mode is excluded from the probe (permissionDecides keeps the pre-existing disabled behaviour).
| checking.value = true | ||
| failedBoardId.value = null | ||
| try { | ||
| const board = await boardsApi.getBoard(boardId) |
There was a problem hiding this comment.
Bound the permission probe before exposing recovery
When the server accepts the connection but never responds, this request has no timeout or abort signal because the shared Axios client defaults to an unlimited timeout. Consequently checking remains true indefinitely, the type selector stays disabled, and the Refresh affordance never appears; pass bounded BoardReadOptions (as the board loader does) so the loading state can reliably transition to the implemented error/recovery state.
AGENTS.md reference: frontend/AGENTS.md:L5-L5
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed at head 653fc95: the probe carries an explicit timeout and skipRetry, and a timed-out read yields the unknown state with recovery, never a grant.
Review round 1 on #3026. The read now uses the same discipline as every other board read - the board timeout and skipRetry - so a 503 or a Retry-After cannot hold the control in 'checking' through the shared retry backoff with the recovery affordance hidden behind it. A superseded or unmounted read is aborted rather than left open. Demo mode has no server to ask and its board fixtures omit canWrite by construction, so it keeps the pre-change behaviour instead of offering a refresh that can never succeed. The retry control stays mounted across the checking and unknown states so activating it does not drop keyboard focus out of the editor.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Review round 1 — independent fresh-context adversarial pass (agent, read-only)Verdict returned: nothing blocking. No path was found where a viewer, an archived board
Re-verified after the fixes, scoped to the fix diff: Not verified: the reviewer had no shell, so its retry/demo-mode reasoning was read from |
|
Coordinator's fresh-context independent review at exact head Verified by the reviewer from source (read-only):
Tracked as #3030 (law 2c):
Author-run proof at this head: 67 passed on the composable and CardModal specs, 118 on neighbouring suites, typecheck, build, eslint, docs checks. Merging with a merge commit once the hosted checks are green and the aging floor is met. |
What
The work-item type selector in the shared card editor gated on
boardStore.currentBoard.canWrite === true.canWriteis optional on theBoardcontract, and
types/board.tssays why: a payload cached before the field existedomits it, and surfaces gating on it must treat only an explicit
falseasread-only. The strict
=== truecheck read that silence as "no", so an otherwiseauthorized writer holding an older cached board payload found the control disabled
with no explanation and no way forward.
This replaces the inline gate with
useCardTypePermission, which answers the questionwith evidence rather than inference:
canWriteis a boolean, or the board isarchived) → gate on it, no request. This is every board loaded from the current server,
so the common path costs nothing.
(
GET /boards/{id}, the caller-scoped response that carries the server-computedCanWrite) and gate on that answer. While the read is open the control stays disabledand says it is checking.
an explicit Refresh permission control instead of a silently disabled selector.
documented legacy only-false convention governs it; an archived board is read-only
whatever the field says.
Viewer, archived board and archived card stay read-only exactly as before and spend no
request. No permission is derived client-side (
permissionsStore.canEditreadsBoardAccessrows, which board owners do not have), and the write itself remainsserver-authoritative — this changes only what the UI offers.
CardModal.vuekeeps a minimal footprint: the composable call plus the bindings on theone
CardModalFormgate. The assignment, archive-recovery, delete-preview, save anddiscard paths landed today by #2977 / #2992 / #3015 / #3011 are untouched.
Follow-up from PR #2949 review comment 3983243109. Closes #2952.
Why not just
canWrite !== falseThat is the reviewer's minimal suggestion and it does fix the disabled-writer case, but
it grants the control on the absence of evidence in a payload of unknown age. Reading
the current permission from the server keeps the gate evidence-based, which is what the
issue's residual acceptance asks for, and leaves the only-false convention applying where
it belongs: to a fresh answer from a server that has no such field.
Tests
src/tests/composables/useCardTypePermission.spec.ts(new, 11 cases): stated true/falsewithout a request; missing
canWrite→ read → granted / denied; fresh payload stillomitting the field → legacy convention; fresh answer says archived → read-only; failed
read → recovery, then granted on the explicit refresh; no automatic retry after a
failure; archived card / unloaded board ask nothing and show no recovery; closed editor
asks nothing; a superseded read never answers another board.
src/tests/components/CardModal.spec.ts(4 new cases): the missing-canWriteregression(checking → enabled after the authoritative read), the failed-read recovery control and
its retry, archived card, archived board. The existing writer-enabled and viewer-disabled
cases are kept, with the viewer case now also proving no permission request is made.
Checks run
npx vitest --run --maxWorkers=2 src/tests/composables/useCardTypePermission.spec.ts src/tests/components/CardModal.spec.ts src/tests/components/CardModalAssignmentSave.spec.ts src/tests/composables/useCardModal.spec.ts src/tests/i18n/catalogs.spec.ts— 169 passednpx vitest --run --maxWorkers=2on the card-editor hosts:ColumnLane.spec.ts,BoardView.spec.ts,paper/PaperBoardView.spec.ts,paper/PaperCardDetailView.spec.ts,paper/boardMutationCapabilityParity.spec.ts,BoardCanvas.spec.ts— 143 passednpm run typecheck— cleannpm run build— cleannpx eslinton every touched file — cleannode scripts/check-docs-governance.mjs,node scripts/check-doc-links.mjs— passNOT verified
vitest --run(OOMs on this box without--maxWorkers); the remainingsuites are CI's to prove.
and the checking state are proven at component level only.
boardsApi.getBoard; the realGET /boards/{id}response shape is unchanged and untouched here.catalogs (Seed an i18n translation layer (vue-i18n) with Italian and Spanish locales #1770).