Skip to content

fix(assignments): settle an assignment save on its own generation counter (#3017) - #3041

Merged
Chris0Jeky merged 5 commits into
mainfrom
issue-3017/assignment-save-settlement
Sep 12, 2026
Merged

Chris0Jeky merged 5 commits into
mainfrom
issue-3017/assignment-save-settlement

Conversation

@Chris0Jeky

Copy link
Copy Markdown
Owner

Closes #3017

Summary

#3017 was an unverified, source-traced hypothesis. It reproduces. Both new specs fail against
main and pass with the fix.

CardAssignmentField shared one generation counter between load() and save(). load()
bumps it at its start; save()'s finally cleared saving only when its own request still
equalled it. The readOnly watcher calls load() whenever the parent reports write permission
again, and that watcher is reachable while a PUT is unanswered — a background board refetch that
re-reports the board mid-save is exactly the trigger the issue names. The save's finally then
no-opped:

  • saving stayed true for the life of the mount, so the Saving assignments… this change was sent and cannot be discarded. status never cleared;
  • locked stayed true, so the participant selector and Save assignments stayed dead;
  • the saving-change emit stayed latched at true, and since Add multiple card assignments and explicit import mapping #2977/Prevent assignment discard from misrepresenting an in-flight save #2981 CardModal refuses
    every close affordance while an assignment save is reported in flight — header close,
    Escape, the Escape stack, the backdrop, the thinking-deck link and archive recovery — so the
    editor became un-closable until navigation remounted it;
  • the save's receipt was dropped too, so baseline was never updated and the field stayed dirty,
    which keeps the host's own save and archive disabled and raises a discard prompt on every close
    path.

Fix

One counter per request kind (loadGeneration, saveGeneration) — the "separate save
generation" option in the acceptance. A read cannot invalidate a write it does not contend with:
a non-refreshing load() only replaces the participant list, and the refreshing one is
unreachable while saving holds (the Refresh button and save()'s own guard are both disabled by
loading/saving). Each counter still rejects its own stale bodies — a superseded save, a read
left behind by a newer read — and the card-identity watcher and onBeforeUnmount bump both,
so a receipt for a card the editor has left is still dropped.

Unchanged on purpose: load()'s stale-response rejection, the #2982 403 permission lock, its
sticky-class rules and its readOnly-transition release, and the three-gate
busy/locked/draftLocked split.

Scope

Verification

Run in the worktree at head 87c6203f8, from frontend/taskdeck-web:

  • npx vitest --run --maxWorkers=2 src/tests/components/CardAssignmentField.spec.ts src/tests/components/CardModalAssignmentSave.spec.ts src/tests/components/CardModal.spec.ts3 files, 81 tests passed.
  • Red-first evidence, same commands against the unfixed component:
    • CardAssignmentField.spec.ts — 2 failed / 15 passed; both failures
      expected [ true ] to deeply equal [ false ] on the last saving-change payload.
    • CardModalAssignmentSave.spec.ts — 1 failed / 10 passed; the save-pending notice
      (card-assignment-save-pending-dismiss, "Keep editing") was still on screen after the PUT
      settled, i.e. the editor was still refusing to close.
  • npm run typecheck (vue-tsc -b) — clean.
  • npm run build947 modules transformed, built in 3.28s.

NOT run: tests/e2e/card-assignments.spec.ts (Playwright, needs a running stack); the rest of
the frontend unit suite (bare vitest --run OOMs on this box, per CLAUDE.md); anything backend —
no backend file is touched.

Docs: no docs/STATUS.md change. STATUS.md records assignments (#2240) as not shipped, so
this fixes a defect in an unshipped surface rather than changing shipped reality — the same call
the #2981/#2982 commits on this component made.

…terrupts a save

A background board refetch that flips readOnly back to writable runs load(),
which shares CardAssignmentField's single generation counter with save(). The
in-flight PUT's finally then no-ops, so saving stays true, locked stays true and
the saving-change the card editor reads to refuse every close affordance stays
latched at true for the life of the mount.

Both new specs fail against current main.

Refs #3017
…nter

CardAssignmentField shared one generation counter between load() and save().
The readOnly watcher is reachable while a PUT is unanswered - a background board
refetch that re-reports write permission mid-save runs load() - and that bumped
the shared counter, so the save's finally no-opped. saving stayed true, locked
stayed true, and the saving-change the card editor reads to refuse every close
affordance (#2977/#2981) stayed latched at true until navigation remounted the
field, trapping the user in the editor.

Split the counter per request kind. Each request still rejects its own stale
body - a superseded save, a read left behind by a newer read - and the
card-identity watcher and unmount bump both, so a receipt for a card the editor
has left is still dropped. A read cannot invalidate a write it does not contend
with: a non-refreshing load() only replaces the participant list, and the
refreshing one is unreachable while saving holds.

The #2982 permission lock, its release rules and load()'s stale-response
rejection are unchanged.

Closes #3017
…ew spec

The spread of the Record-typed mock store loses its index signature through
reactive(), so vue-tsc rejected the currentBoardCards read.

Refs #3017
@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.

…nds after it

Review of #3041 found the mirror of the #3017 overlap: with load() and save()
on separate counters, the save's catch body now runs, so a reload that settles
AFTER a refused PUT could clear the needsRefresh the refusal had just set. That
withdrew the Refresh current assignments button while the alert still told the
user to press it, and re-enabled Save against the version the 409 invalidated.

load() clears saveFailure before it starts, so anything there when its body runs
came from a save that settled during it. Yield needsRefresh in that case. The
spec fails without the guard.

Also correct the counter comment: the read body the split makes safe is the
refreshing branch, which is unreachable while saving holds - not "only the
participant list" - and name needsRefresh as the one shared piece of state.

Refs #3017
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Review triage (one fresh-context adversarial pass, head 87c6203f8)

The Codex connector is posting usage-limit notices instead of reviews, so under global law 2g this
independent pass is the review gate. Verdict: no merge blocker. It traced every writer of
saving, confirmed the "an older save's finally clears a newer save's flag" hazard — the reason
the unconditional-clear option in #3017's acceptance was not taken — cannot occur here (a second
save() returns on locked, and locked ⊇ busy ⊇ saving), and checked the #2982 describe block,
the message set and the three gates line by line as behaviourally untouched.

Three findings, all triaged:

MEDIUM 1 — fixed in 5d9fd2224

The mirror of the #3017 overlap. With the counters split the save's catch body now runs, so a
reload that settles after a refused PUT could clear the needsRefresh the refusal had just set —
withdrawing the "Refresh current assignments" button while the alert still told the user to press
it, and re-enabling Save against the version the 409 had invalidated. Not merge-blocking by 2a (no
data loss, the server's version precondition rejects the stale retries, the editor stays closable),
but it is a state this change makes reachable and the fix is one line with a clear rule, so it
belongs here rather than in a follow-up:

load() clears saveFailure before it starts, so anything there when its body runs came from a save
that settled during the read. load() therefore yields needsRefresh in that case. Verified red
without the guard: expected undefined to be defined on the Refresh button.

The same commit corrects the counter comment, which claimed a non-refreshing load() "only replaces
the participant list". It also writes loading, loadFailed and needsRefresh, and the last of
those is exactly the collision above. The comment now names the refreshing branch as the body the
split makes safe (unreachable while saving holds) and needsRefresh as the one shared piece of
state.

MEDIUM 2 — declined here, tracked as #3042

A 403 that answers after a readOnly true→false transition is now applied instead of discarded, so
the sticky #2982 lock goes back on while its stated release ("until this board reports write
permission again") has already happened — leaving reopening the card as the only exit. This is the
acknowledged cost of taking the "separate save generation" option, and it is strictly better than the
behaviour it replaces, where that same ordering latched the editor un-closable. Resolving it properly
means an authoritative permission read at the moment the 403 lands, which is #3021's declared scope,
so #3042 records the concrete ordering and cross-links it rather than widening this PR into the
permission-read slice.

LOW — fixed in 5d9fd2224

The new CardModal spec asserted only the post-settlement outcome, so it would have passed vacuously
if the reactive board-store stub ever stopped propagating into CardModal.vue's inline read-only
expression. It now asserts getParticipants was called twice, pinning that a second read genuinely
started mid-PUT — the same guard the field-level spec already carried.

The reviewer flagged its own vacuity hypothesis as unverified and asked for a red-run. That was run:
against the unfixed component both new field specs fail on the last saving-change payload
(expected [ true ] to deeply equal [ false ]) and the modal spec fails with the save-pending notice
still on screen after settlement — evidence is in the PR body.

Informational — no action

Test-isolation check (the overridden useBoardStore does not leak; beforeEach re-issues it) came
back clean. The reviewer also noted docs/STATUS.md:29 is stale about #2977 — pre-existing, outside
this PR, and deliberately not touched here.

Verification at head 5d9fd2224

From frontend/taskdeck-web:

  • npx vitest --run --maxWorkers=2 src/tests/components/CardAssignmentField.spec.ts src/tests/components/CardModalAssignmentSave.spec.ts src/tests/components/CardModal.spec.ts3 files, 82 tests passed.
  • npm run typecheck (vue-tsc -b) — clean.
  • npm run buildbuilt in 3.42s.

Still NOT run: the Playwright tests/e2e/card-assignments.spec.ts (needs a running stack) and the
rest of the frontend unit suite (bare vitest --run OOMs on this box).

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Base change: #3032 landed, re-proved against it (head 1d568ae0b)

PR #3032 (#3028) merged as 89fc25178 while this PR was in CI. That PR replaced the inline
assignment read-only expression in CardModal.vue with :read-only="!boardCanWrite || cardIsArchived",
where boardCanWrite now comes from useCardTypePermission rather than from boardStore.currentBoard?.canWrite
directly. My new modal spec drives exactly that expression, so this is a base change that had to be
re-proved rather than merged on trust (global law: a base change counts as a head change).

origin/main merged into the branch cleanly — no conflicts; #3032 did not touch the save/load
settlement logic this PR owns, and the expression itself is still untouched by me.

Re-proved at 1d568ae0b, from frontend/taskdeck-web:

  • npx vitest --run --maxWorkers=2 src/tests/components/CardAssignmentField.spec.ts src/tests/components/CardModalAssignmentSave.spec.ts src/tests/components/CardModal.spec.ts src/tests/composables/useCardTypePermission.spec.ts src/tests/components/CardArchiveAction.requestOwnership.spec.ts5 files, 115 tests passed, i.e. this PR's specs and fix(cards): one server-authoritative permission read for every card editor write gate #3032's new specs together.
  • npm run typecheck (vue-tsc -b) — clean.
  • npm run buildbuilt in 3.00s.

The non-vacuity guard added in the fix round earns its keep here: the modal spec still asserts
getParticipants was called twice after the canWrite flip, which is direct evidence that the flip
still reaches CardAssignmentField through #3032's new boardCanWrite seam and starts a real second
read mid-PUT. Had #3032 broken that propagation, the spec would now fail rather than pass vacuously.

No review round is owed for this: the merge changed no logic of mine and the reviewed findings are
unaffected. Hosted CI re-runs at this head.

@Chris0Jeky
Chris0Jeky merged commit db91dbb into main Sep 12, 2026
36 checks passed
@Chris0Jeky
Chris0Jeky deleted the issue-3017/assignment-save-settlement branch September 12, 2026 01:18
@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