Skip to content

fix(cards): one server-authoritative permission read for every card editor write gate - #3032

Merged
Chris0Jeky merged 5 commits into
mainfrom
issue-3028/card-editor-permission-gates
Sep 12, 2026
Merged

Chris0Jeky merged 5 commits into
mainfrom
issue-3028/card-editor-permission-gates

Conversation

@Chris0Jeky

Copy link
Copy Markdown
Owner

Closes #3028.

Summary

PR #3026 (#2952) made the work-item type selector ask the server for the caller's board
write permission when the loaded Board payload omits the optional canWrite field. Three
sibling gates in the same open editor kept reading that silence as "no", so on a payload
cached before the field existed an authorized writer watched the type selector enable while
the parent selector, the archive/restore control and the assignment editor stayed dead.

One resolved read now answers for all four.

  • useCardTypePermission exposes canWrite, the board-level answer. canEditType keeps its
    exact previous meaning (canWrite plus the card not being archived), and the old
    permissionDecides splits in two: whether an unresolved permission is worth a request
    (readDecides - still excluding an archived card and demo mode exactly as [Cards] Reconcile work-item type editing with older board payloads #2952 decided)
    is not the same question as whether the caller may write to the board. Restoring an
    archived card is a write offered on an archived card, so folding the card's archive
    state into the shared answer would have disabled the one control an archived card exists
    to offer.
  • CardParentField takes canWrite as a required prop and no longer derives one from
    the payload, so no host can silently fall back to the inference that was the defect.
  • CardArchiveAction takes it as an optional override with an undefined default - the same
    idiom as its archived prop - so BoardCardArchive, which has resolved nothing
    server-side, keeps the payload-derived answer it has today.
  • CardModal's assignment read-only input is the shared answer plus the card's archive
    state.
  • The permission message now names editing the board rather than changing the type (en/es/it),
    because one recovery control now speaks for four gates. Key paths and data-testids are
    unchanged.

No client-derived ownership inference anywhere (permissionsStore.canEdit reads BoardAccess
rows, which board owners do not have), and every write stays server-authoritative regardless of
what these controls offer.

Preserved on purpose

  • Viewer, archived board, archived card, demo mode and a card whose board is not the loaded
    one all keep exactly the behaviour they had - including that an archived card spends no
    request ([Cards] Reconcile work-item type editing with older board payloads #2952's decision) and shows no recovery affordance.
  • The assignment field's own 403 lock (Explain and lock assignment controls after edit permission is revoked #2982 / PR Explain and lock assignment controls after edit permission is revoked (#2982) #3011) and its release rules are untouched:
    the lock still releases only on readOnly turning writable, which remains a server-derived
    permission and never a successful participant read. The only new way for that input to turn
    writable is this composable's own server read resolving from unknown to true - evidence of
    the same kind, from the same endpoint.
  • #3030 (the a11y/comment residuals of the same composable) is deliberately untouched: the
    permission region stays in CardModalForm.vue under the same v-if, with the same testids
    and i18n key paths, and the composable keeps its file name, so that slice does not have to
    rebase over a rename. The only overlap is the wording of two strings.

Deviation from the acceptance list, deliberate

The issue asks for a spec where "archived -> all disabled". That holds for an archived
board (all four). For an archived card it holds for the three editable gates, but the
archive control keeps the permission the payload states, because its label there is Restore
card
- disabling it would be a regression, not a gate, and the same acceptance list asks for
archived behaviour to be preserved. Both cases are specified.

Residual, pre-existing and unchanged

An archived card whose board payload omits canWrite still spends no read, so its Restore
stays disabled exactly as before this PR. Fixing that means asking for permission on archived
cards too, which reverses #2952's explicit "no request for an archived card" decision and puts
a recovery affordance under a disabled type selector; out of scope here.

Verification

Run in this worktree (C:/wt/i3028), Node 24, at head 5b9c98bbb:

  • npx vitest --run --maxWorkers=2 src/tests/components/CardModal.spec.ts src/tests/composables/useCardTypePermission.spec.ts src/tests/components/board/CardParentField.spec.ts - 85 passed (3 files).
  • The same three files against the unmodified source (implementation stashed, specs kept):
    8 failed - the six new gate/canWrite cases plus the two that prove the fix. The
    preservation cases (viewer, archived board, archived card + Restore) passed red-side too,
    which is what makes them preservation cases.
  • npx vitest --run --maxWorkers=2 over the ten neighbouring specs that mount these
    components (AppShell, BoardCardArchive, CardArchiveAction, CardAssignmentField,
    CardModalAssignmentSave, ColumnLane, useCardModal, boardMutationCapabilityParity,
    PaperBoardManagement, PaperBoardView) - 322 passed (10 files).
  • npm run typecheck - clean. npm run build - clean.
  • node scripts/check-docs-governance.mjs, node scripts/check-doc-links.mjs - both pass.

NOT run / NOT verified: the full frontend suite (OOMs on this box; hosted Frontend Unit is
the broad evidence), any backend test (no backend surface touched), Playwright/E2E, and real
browser or screen-reader behaviour. The es/it wording is a machine translation of the English
string, not a reviewed one.

…itor

The type selector's server-authoritative permission read (#2952) answered a
question three sibling gates in the same editor were also asking of the same
payload - and still answering with an omitted optional field read as "no".

`canWrite` exposes the resolved answer so one read can serve all of them.
Splitting the old `permissionDecides` in two is what makes that safe: whether an
unresolved permission is worth a request (`readDecides`, still excluding an
archived card and demo mode exactly as #2952 decided) is not the same question as
whether the caller may write to the board. Restoring an archived card IS a write
offered on an archived card, so folding the card's archive state into the shared
answer would have disabled the one control an archived card exists to offer.

`canEditType` keeps its exact previous meaning: the shared answer, plus the card
not being archived.
…ssion

A board payload cached before `canWrite` existed omits it, and the contract in
types/board.ts treats only an explicit `false` as read-only. The parent selector,
the archive/restore control and the assignment field each read that silence as
"no", so after #2952 an authorized writer watched the type selector enable while
three controls beside it stayed dead with nothing to do about it.

All three now answer from the editor's single resolved read:

- `CardParentField` takes `canWrite` as a required prop and no longer derives one
  from the payload, so no host can silently fall back to the inference that was
  the defect.
- `CardArchiveAction` takes it as an optional override with an `undefined`
  default, the same idiom as `archived`: `BoardCardArchive`, which has not
  resolved anything server-side, keeps the payload-derived answer.
- The assignment field's `read-only` input is the shared answer plus the card's
  archive state. Its own 403 lock (#2982) is untouched: the lock still releases
  only on `readOnly` turning writable, which remains a server-derived permission
  and never a successful participant read.

Demo mode, a card whose board is not the loaded one, a viewer, an archived board
and an archived card all keep exactly the behaviour they had. The permission
message now names editing the board rather than changing the type, because one
control's recovery now speaks for four.
One resolved read now gates four controls, not one. STATUS carries the shipped
behaviour with its local evidence and its residual; the work-item type page keeps
the mechanism next to the #2952 text it extends.
@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.

Review round 1 on #3032.

The parent-field case set a board payload it no longer reads, and a comment that
claimed the omitted-payload journey as its subject; the prop alone is what it
proves, and the journey is covered in CardModal.spec.ts. The work-item type page
now carries the archived-card exception that STATUS already recorded.

No behaviour change.
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Review round 1 - independent fresh-context adversarial pass, triaged

The Codex connector posted only a usage-limit notice on this PR, so no connector review will arrive.
One fresh-context adversarial review (read-only agent, given the full diff, the issue text and the
worktree) carries the gate instead. Verdict: no CRITICAL, no HIGH. Two MEDIUM, five LOW, each
triaged once below.

Fixed here (5863e8f6f, no behaviour change)

  • LOW: the parent-field case proved less than its comment claimed. It set a board payload the
    component no longer reads, with a comment claiming the omitted-payload journey as its subject. The
    inert line is gone and the comment now says what it proves - that the prop alone decides - and
    points at CardModal.spec.ts for the journey.
  • LOW: docs/product/CARD_WORK_ITEM_TYPES.md overstated the invariant. It now carries the
    archived-card exception that docs/STATUS.md already recorded.

Tracked

Declined, with reasons

Verification after the fix push

npx vitest --run --maxWorkers=2 src/tests/components/board/CardParentField.spec.ts - 7 passed.
npm run typecheck - clean. node scripts/check-docs-governance.mjs, node scripts/check-doc-links.mjs

  • both pass. The fix diff touches one spec comment and one doc sentence, so no other check was re-run
    locally; hosted CI re-runs the whole gate at the new head.

Not verified: nothing was executed by the reviewer - it is a read-only agent; its refutations are
source-traced. The withDefaults(..., { canWrite: undefined }) behaviour it flagged as unverified is
exercised by BoardCardArchive.spec.ts (restore succeeds with no canWrite prop) and
CardArchiveAction.spec.ts, both of which were in the 322-test neighbouring run and passed.

@Chris0Jeky
Chris0Jeky merged commit 89fc251 into main Sep 12, 2026
36 checks passed
@Chris0Jeky
Chris0Jeky deleted the issue-3028/card-editor-permission-gates branch September 12, 2026 00:47
@github-project-automation github-project-automation Bot moved this from Pending to Done in Taskdeck Execution Sep 12, 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

1 participant