Skip to content

Explain and lock assignment controls after edit permission is revoked (#2982) - #3011

Merged
Chris0Jeky merged 8 commits into
mainfrom
issue-2982/assignment-permission-loss
Sep 11, 2026
Merged

Chris0Jeky merged 8 commits into
mainfrom
issue-2982/assignment-permission-loss

Conversation

@Chris0Jeky

Copy link
Copy Markdown
Owner

Closes #2982

What

CardAssignmentField folded a 403 on the assignment PUT into the generic
unknown-outcome branch: "Could not confirm assignment save. Refresh before
retrying." This change classifies the save failure and makes the forbidden case
its own, sticky state.

  • saveFailure is now a class (permission / conflict / ineligible /
    unknown) instead of a pre-rendered string, and the alert copy is derived
    from it. Non-permission classes behave exactly as before.
  • permission (403) explains that edit permission was revoked and locks the
    write controls: it feeds locked, so the participant selector and
    Save assignments are disabled, and the Save button now names locked in its
    own :disabled binding instead of relying only on ancestor fieldset
    propagation.
  • The draft is preserved and read access is kept: the selection stays checked,
    the current assignees stay rendered, and "Refresh current assignments" still
    works as a read.

Why the lock is sticky

A downgrade to Viewer leaves every read this field makes working. So a
successful participants refresh is not evidence of write permission, and a
board canWrite cached from before the downgrade is not evidence either.
Clearing the lock on either signal is what produced the repeated rejected
saves the review comment described
(#2977 (comment)).

permission is therefore cleared only by:

  • the parent's server-derived readOnly input transitioning back to writable
    (the field's authoritative permission input), or
  • a different card or session (the existing identity watcher).

A completed load() clears everything except permission. The existing
generation guard is untouched, so a 403 that settles after a newer request is
still dropped before it can set the class.

This matches the permission vs transient idiom PR #2991 shipped in
CardParentField.vue, so the two fields read the same way.

Scope

Frontend only: CardAssignmentField.vue, its spec, and one sentence in
docs/product/CARD_ASSIGNMENTS.md. CardModal.vue / useCardModal.ts were
deliberately not touched (owned concurrently by #2969); no change was needed
there.

Checks run

  • npx vitest --run --maxWorkers=2 src/tests/components/CardAssignmentField.spec.ts - 12 passed
    (5 new: downgrade-before-save locks with the explanation and a kept draft; a
    successful Viewer participants refresh does not unlock; restored parent
    permission unlocks and saves; a superseded 403 is ignored; the 400 path stays
    refreshable and unlockable, in contrast to the permission path)
  • npx vitest --run --maxWorkers=2 src/tests/components/CardModal.spec.ts src/tests/components/CardModalAssignmentSave.spec.ts - 49 passed
  • npm run typecheck - clean
  • npm run build - clean
  • npx eslint on both touched frontend files - clean
  • node scripts/check-docs-governance.mjs - passed
  • node scripts/check-doc-links.mjs - passed (695 files, 0 broken links)

NOT verified

  • No runtime reproduction against a live backend: there is no seeded
    editor-downgraded-to-Viewer session here, so the 403 is exercised only
    through the mocked cardsApi in the component spec. The issue itself notes
    the finding is source-traced and not runtime-reproduced.
  • No Playwright/E2E run (card-assignments.spec.ts needs a running stack).
  • No backend test run: no backend file is touched; server-side rejection
    behavior is unchanged and already covered by CardAssignmentApiTests.
  • The full frontend vitest suite was not run (targeted specs only, per the
    repo's proving-check table).
  • Whether a real parent ever flips readOnly back to writable without
    remounting the card is not proven here; if it does not, the card/session
    identity reset (reopening the card) remains the release path.

A 403 on the assignment PUT was folded into the generic unknown-outcome
branch, which told a downgraded editor to refresh and retry. A Viewer's
participant read still succeeds and a board canWrite cached from before the
downgrade still says writable, so that advice produced a retry loop the
server would keep refusing.

Classify the save failure (permission / conflict / ineligible / unknown) and
make only the permission class sticky: it explains that edit permission was
revoked, locks the selector and Save assignments, and is not cleared by a
successful read. The draft and the current assignees stay readable. Only the
parent's server-derived readOnly input turning writable again, or a different
card/session, releases the lock. The existing generation guard still drops a
403 that settles after a newer request.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-11T19:29:29.806712Z 0c7d319 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4bc46a5c57

ℹ️ 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".

Comment on lines +114 to +117
watch(() => props.readOnly, readOnly => {
if (!readOnly) {
saveFailure.value = null
void load()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recheck authority before keeping assignments locked

When the board store still caches canWrite: true across a Viewer downgrade, readOnly remains false when the PUT returns 403; restoring Editor access and even refetching a writable board also leaves that boolean false, so this watcher never fires and permissionLost stays sticky until the card/session is remounted. This is the normal quiet-board path because BoardAccessService.UpdateAccessAsync does not publish a board mutation. Use an authoritative permission-refresh signal rather than requiring a false-to-true readOnly transition.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Triaged once: real, non-blocking (the copy names reopening as a release path, so nothing is misrepresented). Tracked as #3021: drive the lock release from an authoritative permission read.

Comment on lines +42 to +43
if (permissionLost.value) return 'Your edit permission was revoked, so this assignment save was refused. Assignment editing stays locked until your board access is restored — refreshing will not unlock it. Your draft and the current assignees stay readable.'
if (loadFailed.value) return 'Could not load current participants. Your draft is kept.'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Surface refresh failures after permission loss

If the user clicks Refresh after the assignment PUT returned 403 and either follow-up GET fails—for example because access was then fully revoked or the network failed—loadFailed becomes true while saveFailure remains permission. This ordering always renders the old permission message, including the claim that current assignees remain readable, and suppresses the refresh-failure error while stale assignees remain displayed; show both outcomes or give the failed read precedence.

AGENTS.md reference: frontend/AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in round 2 (e78b11f..0c7d319): a failed refresh after permission loss renders one combined message and keeps Clear/Cancel usable.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Fresh-context independent review (round 1) at exact head 4bc46a5c57153765b264ed0448a976d203bf329d against base 4c479a7ff311912ffb96da027f6e525521bb70df: no CRITICAL/HIGH, one MEDIUM regression taken into the fix round, LOWs recorded. A fix round is in progress on this branch.

Verified by the reviewer from source (read-only):

  • The lock cannot be bypassed: save() still hard-returns on locked, the button is its only caller, there is no form to submit implicitly, the checkboxes and buttons sit inside <fieldset :disabled="locked">, and Save carries an explicit :disabled as well.
  • Every writer of saveFailure is generation-gated or identity-scoped (save catch after the generation check, load start, the card/session identity watcher, the readOnly watcher). A successful participants read cannot clear it; readOnly traces to server-derived boardStore.currentBoard.canWrite; nothing derives write permission client-side.
  • Release paths exist in both hosts: the field is remounted on close/reopen, and the background board refetch supplies the in-place readOnly true to false transition.
  • Server-side, 403 on the assignment PUT is only ever a permission fact; archived/conflict/eligibility map to other codes. The 409/400/unknown copies are byte-identical to before and the alert keeps role="alert".

Fix-now (MEDIUM, introduced by this head): folding permissionLost into locked also disables Clear and Cancel, so after a 403 the draft can never be dismissed for the life of the mount; dirty stays true, the host keeps the card's own Save Changes and archive action disabled, and every close raises the discard dialog. Before this head a refresh re-enabled Cancel. The fix round keeps Clear and Cancel usable while locking only the selector and Save, with a spec.

Non-blocking (law 2c):

  • LOW, folded into the fix round because it is the same lines: the UI copy and the doc sentence overstate the release condition (the only in-place release is a readOnly transition; reopening the card also unlocks). The pre-existing doc sentence "Settlement restores every control" is amended for the 403 case.
  • LOW, folded into the fix round: the "stays locked after a participant refresh" spec clicks a disabled button, which is a no-op in test-utils; the save() guard is now exercised directly.
  • LOW, informational: after a 403, a failed participants refresh keeps showing the permission text (permission outranks load failure); recoverable.
  • LOW, pre-existing and unverified, tracked as [Frontend][Assignments] A load() during an in-flight assignment save can latch saving=true and leave the editor un-closable (from PR #3011 review, unverified) #3017: a load() during an in-flight save can latch saving and leave the editor un-closable.

Review round 2. Folding the permission lock into the single `locked` that
disabled the whole fieldset also disabled Clear and Cancel, and the 403 branch
additionally set needsRefresh. After a 403 the draft could therefore never be
returned to baseline for the life of the mount: dirty stayed true, the host's
assignmentDirty kept the card modal's own save and archive disabled, and every
close path raised the discard dialog. A refresh used to re-enable Cancel.

Split the gate. `busy` is every reason the whole field is non-interactive and
drives Clear and Cancel; `locked` is `busy` plus the revoked permission and
drives only the selector fieldset and Save assignments. The button row moves
out of the selector fieldset so each control names its own condition. The 403
branch no longer sets needsRefresh - a refusal is not a stale-state claim and
the permission class carries its own lock - and the refresh affordance is shown
for the permission case directly.

Copy now states the real release condition (the board reporting write
permission again, which the background refetch delivers, or reopening the card)
instead of claiming a refresh can never unlock it, and names Clear and Cancel
as still working. The product doc says the same and no longer claims settlement
restores every control.

The refresh-does-not-unlock spec now calls save() through the instance so the
`if (locked) return` guard is exercised, rather than clicking a disabled button
that test-utils never dispatches. New spec covers 403 then Cancel.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e78b11f95f

ℹ️ 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".

<!-- Outside the selector fieldset on purpose: Clear and Cancel only edit the local
draft, so they follow `busy` and survive a revoked permission. Save is the write
and names `locked` itself rather than relying on ancestor propagation. -->
<div v-if="!readOnly" class="flex gap-3">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep draft actions visible after the read-only update

When an assignment PUT returns 403 with a dirty draft and a later board refresh correctly changes canWrite to false, CardModal changes this prop to readOnly=true; this v-if then removes both Clear and Cancel even though the new permission message promises they still work. assignmentDirty consequently remains true, disabling the other card save/archive actions and requiring the user to discard or reopen the whole editor. Keep the local draft actions available in the permissionLost state while continuing to lock Save.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in round 2 (e78b11f): draft actions stay rendered and enabled after the board reports read-only; only Save is removed.

Self-review of the round-2 fix: the new cancel test had the same weakness the
reviewer flagged on the save test. Cancel used to be disabled by the ancestor
`<fieldset :disabled="locked">` without carrying its own disabled attribute,
and jsdom does not reflect ancestor disabling onto button.disabled - so both
the attribute check and the click passed against the buggy structure while the
control was dead in a real browser. Assert closest('fieldset[disabled]') is
null for Cancel and Clear, which is what actually regressed.
…ed refresh (#2982)

Two Codex P2 threads on this round, both inside the lines the round already
owns, both cases of the fix's own copy promising something untrue.

1. The permanently-dirty host had a second route. When the board refetch catches
   up and correctly reports canWrite:false, CardModal sets readOnly=true, and
   v-if="!readOnly" removed Clear and Cancel outright - so dirty stuck again and
   the new message's "Clear and Cancel still work" became false exactly when the
   user needed them. Discarding a draft is a local action, never a write, so
   draftLocked now drops readOnly while permissionLost holds; the row renders in
   that state and Save alone stays hidden, because the board does say read-only.

2. A refresh that failed AFTER the refusal set loadFailed while saveFailure
   stayed 'permission', and the precedence chain rendered only the permission
   message - hiding the read failure while still claiming the current assignees
   were readable, with stale assignees on screen. The two states now render one
   combined message that reports both and drops that promise.

The readOnly-transition thread is declined here, not fixed: unlocking without a
remount needs an authoritative permission-refresh signal, which lives outside
this field's owned paths. The copy already names reopening the card as the
release path, so it is honest about the limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a8c420e398

ℹ️ 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".

* alone stops blocking the draft-side controls; every other reason still does,
* and Save stays gone because the board says this field is read-only.
*/
const draftLocked = computed(() => archived.value || props.disabled || loading.value || saving.value || needsRefresh.value || (props.readOnly && !permissionLost.value))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep draft actions enabled after a failed refresh

When a permission-loss 403 is followed by a failed Refresh, load() sets needsRefresh to true and this expression disables both Clear and Cancel. Although the combined alert now reports the read failure, if read access has been revoked or the network remains unavailable, assignmentDirty cannot return to baseline, so CardModal continues blocking other card saves and archive actions until the user discards or reopens the whole editor. Keep the draft-only cancellation controls usable while permissionLost is active.

AGENTS.md reference: frontend/AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in round 2 (0c7d319): a failed refresh no longer re-locks Clear/Cancel while permission is lost.

…es (#2982)

The busy comment still described it as driving Clear and Cancel, which stopped
being true when draftLocked took them over. Replace both comments with one
block naming busy (shared base), locked (the write path: selector, Save,
save()) and draftLocked (the local draft discard), and why the last one exists.
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Round 2 fixes pushed

Head is now f3ad8334a88cf3c133ad20d914a83c4d5cfe2ce9. Five commits this round:

  • e78b11f95 — the reported regression and the two LOW items
  • ff054a031 — my own review of that fix: a vacuous assertion in its new test
  • a8c420e39 — the two Codex threads that land inside these lines
  • 1f7e0d6fa, f3ad8334a — code comments the above left describing the old gating

MEDIUM regression: the draft could never be dismissed after a 403 — fixed

Confirmed, and worse than a disabled button: folding permissionLost into the
single locked that drove <fieldset :disabled="locked"> disabled Clear and
Cancel along with the selector, and the 403 branch also set needsRefresh.
So after a 403 dirty stayed true for the life of the mount, the host's
assignmentDirty kept the card modal's own Save Changes and the archive action
disabled, and every close path raised the discard dialog. Pre-PR, a refresh
re-enabled Cancel; this PR removed even that.

Confirmed in CardModal.vue rather than assumed: assignmentDirty comes
straight from this field's @dirty-change (L379) and feeds
hasUnsavedChanges (L64), which disables CardArchiveAction (L381) and gates
the close/discard paths (L214, L327), while is-form-valid carries
&& !assignmentDirty (L437). The sharpest symptom is L435: the modal renders
"Save or cancel assignment changes before saving other card fields." — after a
403 that was an instruction the user could not obey, because both named actions
were disabled.

The gate is now split rather than one flag:

  • busy — every reason the whole field is non-interactive (readOnly,
    archived, disabled, loading, saving, needsRefresh). Drives Clear and
    Cancel. A revoked permission deliberately does not reach it.
  • lockedbusy plus permissionLost. Drives only the selector fieldset
    and Save assignments, and still guards save().

The button row moved out of the selector fieldset so each control names its own
condition instead of inheriting one. The 403 branch no longer sets
needsRefresh (needsRefresh.value = !permissionLost.value): a refusal is not
a stale-state claim, and needsRefresh would have re-disabled the draft-side
controls through busy. The refresh affordance is rendered for the permission
case directly (v-if="needsRefresh || permissionLost"), so read access is
unchanged.

New spec: 403, then Cancel -> dirty-change emits false, the checkbox clears,
no second PUT, while the selector fieldset stays disabled, Save stays disabled
and the explanation stays on screen. Cancelling a draft is not regaining
permission.

LOW: copy vs. the real release condition — fixed

"refreshing will not unlock it" was an absolute claim the code does not make.
Both the UI string and the doc now name the actual release condition: the board
reporting write permission again (the readOnly true->false transition the
background board refetch delivers) or reopening the card. The UI copy also
names the locked surface precisely ("the participant selector and Save
assignments") and says Clear and Cancel still work.

docs/product/CARD_ASSIGNMENTS.md: the pre-existing sentence "Settlement
restores every control" now reads "restores every control except after a
refusal for lost edit permission", and the added sentence states the release
condition and that Clear and Cancel stay available.

LOW: the test re-asserted the attribute instead of the guard — fixed

Correct: test-utils skips the dispatch on a disabled button, so that click
proved nothing about save(). The assertion now calls the handler through the
instance:

await (wrapper.vm as unknown as { save: () => Promise<void> }).save()
expect(cardsApi.replaceAssignments).toHaveBeenCalledTimes(1)

if (locked.value || !dirty.value) return is now what keeps the count at 1 —
the test fails if permissionLost stops feeding locked.

The same weakness was in my own new Cancel test — caught and fixed (ff054a031)

Applying the reviewer's point to the rest of the round: the new
403-then-Cancel test asserted cancel.attributes('disabled')).toBeUndefined().
That is vacuous against the very regression it guards. Cancel was disabled by
the ancestor <fieldset :disabled="locked"> without carrying its own
attribute, and jsdom does not reflect ancestor disabling onto
button.disabled — so both the attribute check and the subsequent click passed
against the buggy structure while the control was dead in a real browser. The
test now asserts the ancestry, which is what actually changed:

expect(cancel.element.closest('fieldset[disabled]')).toBeNull()
expect(button(wrapper, 'Clear').element.closest('fieldset[disabled]')).toBeNull()

Falsified rather than assumed: I temporarily moved the button row back inside
the fieldset and re-ran. The suite went to 1 failed | 12 passed with
AssertionError: expected <fieldset …> to be null on the closest line — and
the attributes('disabled') line above it still passed, which is the direct
evidence that the original assertion proved nothing. Component restored and
byte-compared before committing.

Codex threads triaged (a8c420e39)

Two of the three are inside the lines this round already owns, and both are
cases of this fix's own copy promising something untrue — so they were fixed
here rather than deferred to a round that is not coming.

Keep draft actions visible after the read-only update — fixed. Correct, and
it is the same stranded-draft defect reached by the other route: when the board
refetch catches up and reports canWrite: false, CardModal sets
readOnly=true, and v-if="!readOnly" removed Clear and Cancel outright. So
dirty stuck again, and the new message's "Clear and Cancel still work" became
false exactly when the user needed them. Discarding a draft is a local action,
never a write, so a new draftLocked drops readOnly while permissionLost
holds; the row renders in that state and only Save stays hidden, because the
board genuinely does say read-only. New spec: 403, then readOnly=true ->
Save gone, Cancel present, enabled, not inside a disabled fieldset, and
clicking it returns dirty-change to false.

Surface refresh failures after permission loss — fixed. Also correct. A
refresh failing after the refusal set loadFailed while saveFailure stayed
permission, and the precedence chain rendered only the permission message —
hiding the read failure while still claiming the current assignees were
readable, with stale assignees on screen. The two states now render one
combined message reporting both, and it drops the readable-assignees promise.
New spec asserts both facts appear and that the stale promise is gone.

Recheck authority before keeping assignments locked — declined here, tracked.
The observation is accurate: on the quiet-board path the cached canWrite: true
means readOnly never transitions false->true, so restoring Editor access does
not unlock until the card is reopened. That is the behaviour issue #2982
specified ("unlock only when the field's readOnly/permission input from the
parent changes to writable ... or when an explicit refreshed permission says
so"), and the UI copy names reopening the card as a release path, so it is
honest about the limit rather than silently wrong. Fixing it properly needs an
authoritative permission-refresh signal — a board/permission re-read or a store
signal — which is outside this field's owned paths, and this slice is explicitly
frontend-only and must not touch CardModal.vue / useCardModal.ts (#2969 owns
them). Recommend a follow-up issue for the refresh signal; I have not opened one,
since issue creation is the coordinator's call.

Checks at this head

  • npx vitest --run --maxWorkers=2 src/tests/components/CardAssignmentField.spec.ts src/tests/components/CardModal.spec.ts src/tests/components/CardModalAssignmentSave.spec.ts — 64 passed (15 in the assignment field spec, 3 new this round)
  • npm run typecheck — clean (vue-tsc -b, so the template bindings of the
    changed SFC are type-checked, not just the script)
  • npm run buildnot completed locally at this head. It built clean at
    4bc46a5c5 earlier in this session, but the box has been memory-saturated by
    parallel workers since (85–400 MB free against the ~2.5 GB this build needs),
    and I will not start a build that would thrash the machine and other agents'
    runs. A wait-then-build is still queued. The component is nonetheless compiled
    and executed by two independent toolchains at this head — vue-tsc and
    Vitest, which mounts and renders it 15 times — and CI runs the production
    build on this PR, which is the authority for it.
  • npx eslint on both touched frontend files — clean
  • node scripts/check-docs-governance.mjs, node scripts/check-doc-links.mjs — passed

Still NOT verified

Unchanged from the PR body: no runtime reproduction against a live backend, no
E2E run, no backend tests (no backend file touched), and the full frontend
vitest suite was not run. The host consequence above is traced through
CardModal.vue's source, not observed in a browser: the new spec proves this
field's side of it (dirty-change returns to false), and CardModal.vue was
not modified or re-tested beyond its existing spec passing, since #2969 owns it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f3ad8334a8

ℹ️ 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".

// The sticky permission message must not swallow a read that failed AFTER it,
// nor keep promising readable assignees once the refresh stopped confirming them.
if (permissionLost.value && loadFailed.value) return 'Your edit permission was revoked, so this assignment save was refused, and the latest refresh also failed — the assignees shown may be out of date. The participant selector and Save assignments stay locked until this board reports write permission again or you reopen the card. Your draft is kept; refresh again to confirm the current assignees.'
if (permissionLost.value) return 'Your edit permission was revoked, so this assignment save was refused. The participant selector and Save assignments stay locked until this board reports write permission again or you reopen the card. Your draft and the current assignees stay readable, and Clear and Cancel still work.'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle full access revocation separately from Viewer downgrade

When a manager removes the user's board access entirely between the initial load and the PUT, CardAssignmentService.StageReplaceAsync returns the same 403 as it does for a Viewer downgrade because both fail CanWriteBoardAsync. This branch nevertheless claims that the current assignees remain readable, even though revocation also detaches that user from assignments and subsequent participant/card reads will fail, leaving only stale cached data until Refresh corrects the message. Avoid inferring retained read access from the PUT status alone; use a neutral permission message until a read succeeds or distinguish the state using an authoritative follow-up read.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Triaged once: real, non-blocking (server rejection is correct; the explanation is wrong for full revocation). Tracked as #3021 together with the quiet-board release path.

Third and last route to the same stranded draft, reported by Codex on the
previous commit. A Refresh that fails after the 403 sets needsRefresh, which
draftLocked still honoured, so Clear and Cancel died again - and if read access
is gone for good or the network stays down, the host is permanently dirty with
no exit but discarding or reopening the whole editor. I had papered over this
one in the combined alert text rather than fixing it; that was the weaker
choice, because the text then promised controls that were disabled.

While permissionLost holds, neither readOnly nor needsRefresh blocks the
draft-side controls. What still blocks them is only what makes discarding
meaningless or unsafe: an archived card, an in-flight read or write, or a host
that disabled the field. The combined message can therefore say again that the
draft can be cleared or cancelled, and now it is true.
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Third route to the stranded draft — fixed (0c7d319e4)

Head is now 0c7d319e4951bd7b3e2442ab1bace4f7cab7fb93.

"Keep draft actions enabled after a failed refresh" — correct, and fixed.
A Refresh that fails after the 403 sets needsRefresh, which draftLocked
still honoured, so Clear and Cancel died again. If read access is gone for good
or the network stays down, that is the same permanently-dirty host with no exit
but discarding or reopening the whole editor.

Worth naming plainly: I saw this one while writing the previous commit and
papered over it in the combined alert text instead of fixing the behaviour. That
was the weaker choice — the text then promised controls that were disabled. The
gate is the fix:

const draftLocked = computed(() => archived.value || props.disabled || loading.value
  || saving.value || (!permissionLost.value && (props.readOnly || needsRefresh.value)))

While permissionLost holds, neither readOnly nor needsRefresh blocks the
draft-side controls. What still blocks them is only what makes discarding
meaningless or unsafe: an archived card, an in-flight read or write, or a host
that disabled the field. The combined message says again that the draft can be
cleared or cancelled, and it is now true.

The refresh-failure spec was extended rather than duplicated: after the failed
refresh it now asserts Cancel is enabled, clicks it, and checks dirty-change
returns to false.

That closes all three routes into the defect this round was opened for — the
single gate, the readOnly flip, and the failed refresh.

Checks at this head

  • npx vitest --run --maxWorkers=2 src/tests/components/CardAssignmentField.spec.ts src/tests/components/CardModal.spec.ts src/tests/components/CardModalAssignmentSave.spec.ts — 64 passed
  • npm run typecheck — clean
  • npx eslint on both touched files — clean
  • npm run build — still not completed locally; the box remains memory-saturated
    (see the previous comment). CI's production build on this PR is the authority.

Remaining open thread — not fixed here

"Recheck authority before keeping assignments locked" stands as declined in my
previous comment: unlocking without a remount needs an authoritative
permission-refresh signal outside this field's owned paths, and this slice is
frontend-only and must not touch CardModal.vue / useCardModal.ts (#2969 owns
them). The UI copy names reopening the card as a release path, so the behaviour
is documented rather than silently wrong. It wants its own issue.

I am stopping fixes on this PR here.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Build correction

npm run build has now completed locally at 0c7d319e4, exit 0 — the memory
pressure cleared and the queued wait-then-build ran. vue-tsc -b clean, vite build ✓ built in 3.90s, PWA generateSW emitting dist/sw.js and
dist/workbox-1c53b24d.js.

So the two earlier comments' "not completed locally" note is superseded: every
proving check for this seam is green at the head.

  • npx vitest --run --maxWorkers=2 src/tests/components/CardAssignmentField.spec.ts src/tests/components/CardModal.spec.ts src/tests/components/CardModalAssignmentSave.spec.ts — 64 passed
  • npm run typecheck — clean
  • npm run build — clean (exit 0)
  • npx eslint on both touched frontend files — clean
  • node scripts/check-docs-governance.mjs, node scripts/check-doc-links.mjs — passed

Still NOT verified, unchanged: no runtime reproduction against a live backend,
no Playwright/E2E run, no backend tests (no backend file touched), and the full
frontend vitest suite was not run — targeted specs only, per the repo's
proving-check table.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0c7d319e49

ℹ️ 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".

*/
const busy = computed(() => props.readOnly || archived.value || props.disabled || loading.value || saving.value || needsRefresh.value)
const locked = computed(() => busy.value || permissionLost.value)
const draftLocked = computed(() => archived.value || props.disabled || loading.value || saving.value || (!permissionLost.value && (props.readOnly || needsRefresh.value)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep draft cancellation enabled after archival refresh

When a 403-locked draft is followed by Refresh after another editor archives the card, load() sets archived to true while deliberately preserving selected; this term then disables both Clear and Cancel even though the draft can remain dirty. CardModal uses that dirty state to disable CardArchiveAction, so the user cannot even restore the card and must discard or reopen the whole editor. Keep local draft cancellation available in the permissionLost state when the refreshed card is archived, or reset the draft when installing that archived baseline.

Useful? React with 👍 / 👎.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Round 2 (fix round) review at exact head 0c7d319e4951bd7b3e2442ab1bace4f7cab7fb93, fix range 4bc46a5c5..0c7d319e4: CLEAN on the blocking bar. Round ceiling reached (law 2d); shipping once the checks at this head are green and the aging floor is met.

Scoped fresh-context verification of the fix:

  • draftLocked truth table: with no permission loss it is term-for-term identical to the base locked, so the base 409 contract (Cancel inert until the refresh settles) is preserved; with permission lost, Clear/Cancel stay usable after the board confirms the downgrade and after a failed refresh; Save is removed only when the board reports read-only.
  • Cancel under permission loss restores the true pre-edit baseline, emits dirty-change: false, so the host re-enables its own Save Changes and stops prompting discard, while the lock and explanation survive.
  • Moving the button row out of the fieldset changes neither document order nor Tab order; disabled-by-ancestor and by-attribute are equivalent for the focus trap; save() keeps its locked || !dirty guard so a programmatic call cannot PUT.
  • The 403 branch no longer sets needsRefresh; a refresh cannot bump the generation under an in-flight PUT through this path (the [Frontend][Assignments] A load() during an in-flight assignment save can latch saving=true and leave the editor un-closable (from PR #3011 review, unverified) #3017 shape is not reachable here).
  • Copy: the background board refetch really re-reads canWrite, so "the board reports write permission again" is a real release mechanism; reopening the card is the other.
  • The three new assertions demonstrably fail against the round-1 head (falsification run recorded by the author).

Triage of everything raised since round 1 (law 2c), all non-blocking:

Author-run proof at this head: targeted vitest 64 passed, typecheck, build (exit 0), eslint, docs checks. Base has moved since the branch point; the merge state is re-read before merging.

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.

Explain and lock assignment controls after edit permission is revoked

1 participant