Skip to content

fix(cards): keep card labels within the card's own board - #569

Open
nickmeinhold wants to merge 4 commits into
kanbn:mainfrom
nickmeinhold:fix/card-label-board-scope
Open

fix(cards): keep card labels within the card's own board#569
nickmeinhold wants to merge 4 commits into
kanbn:mainfrom
nickmeinhold:fix/card-label-board-scope

Conversation

@nickmeinhold

@nickmeinhold nickmeinhold commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Description

Labels are board-scoped (labels.boardId), but _card_labels stores only (cardId, labelId), so the schema cannot reject a card on one board holding a label from another. Every permission check on these paths is workspace-scoped, and a workspace holds many boards.

Five writers touch the join table (insert(cardsToLabels)). Four could produce an invalid link:

Writer Before
card.create inserts any labelPublicIds unchecked
card.duplicate target list need only share a workspace, so a cross-board copy carried the source board's labels
card.addOrRemoveLabel resolves a label by publicId, links it with no board comparison
labelRepo.create (with a cardId) creates a label on one board, links it to a card on another

The importer is already correct, because it creates fresh labels on the board it builds.

Reachable today through the public API and MCP, not through the UI, because the pickers are board-scoped.

The fix is enforced in the repository writers rather than the routers, so it holds for future callers. The check and the insert share one transaction with the card rows locked (FOR UPDATE OF card, ordered by id), because a card's board is a property of whatever list its listId points at.

Also fixed along the way:

  • card.create validated labels after creating the card, so a bad label meant an orphaned card and a 500. It validates first and returns BAD_REQUEST.
  • card.duplicate had the same ordering problem, and retrying as its own error message suggested produced a second card.
  • A partial label lookup counted as success, silently creating a card with fewer labels than requested.
  • CardLabelBoardError carries a closed violation type, so "unknown card", "unknown label" and "board mismatch" stop sharing one string.

Known limits, stated rather than implied:

  • card.update can still move a card to another board, leaving the labels it already holds invalid. No join row is written, so a writer guard cannot see it. What should happen to those labels (drop, match by name, recreate, block the move) is a product decision and the same question as Move cards between boards #568, so I have not answered it here.
  • The FOR UPDATE lock is not covered by a test. Removing it leaves all 12 green. Proving it needs a contention test.

Happy to take this in a different direction if you would rather the invariant live in the schema.

Reviewing

Most of the diff is the new test file. label.repo.ts reads as ~60 lines but is 15 insertions and 2 deletions ignoring whitespace, the rest being re-indentation from wrapping the body in a transaction.

https://github.com/kanbn/kan/pull/569/files?w=1

Type of change

  • Bug fix
  • Feature (requires an approved issue — see below)
  • Refactor / chore
  • Documentation

Checklist

  • I have linked the related issue below
  • My code follows the existing style and conventions
  • I have tested my changes locally
  • I have included screenshots for any UI changes — no UI changes in this PR

Linked issue

Relates to #568 (deliberately not Closes: that issue asks for cross-board card moves, which this PR does not implement. It fixes the label scoping that feature would otherwise expose.)


12 integration tests against real Postgres. Every cross-board assertion was confirmed red before its fix, with same-board positive controls green throughout.

nickmeinhold and others added 4 commits September 1, 2026 09:32
Labels are board-scoped (labels.boardId), but _card_labels stores only
(cardId, labelId), so the schema cannot reject a card on one board holding a
label from another. Every permission check on the label paths is
workspace-scoped, and a workspace holds many boards, so nothing enforced it.

Three writers could produce the invalid link, not one:

- card.create accepts any labelPublicIds and inserts them unchecked
- card.duplicate copies labels to a target list that only has to be in the same
  workspace, so duplicating to another board carried the source board's labels
- card.addOrRemoveLabel resolves a label by publicId and links it with no
  board comparison

Only the board importer was correct, because it creates fresh labels on the
board it is building.

Enforced in the repository writers rather than in the three routers, so the
invariant holds for any future caller by construction. The check fails closed:
an unknown card or label is refused, since without both boards there is no
evidence the link is legitimate.

card.duplicate now selects the labels that belong to the destination board
before writing, so duplicating a card to another board still works and carries
the labels that legitimately transfer, rather than failing outright.

Reported indirectly: the Discord #help thread "Moving cards between board"
asks for cross-board card moves. The UI cannot do it today, but card.duplicate
already accepts a list on another board, which is what makes this reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBJ4E8uw7c2Ctdyb5Dcnen
…oard copies

Cage-match round 1 on this branch (Kelvin, Carnot, Tesla), 4/4 seats, all
REQUEST_CHANGES. Six real findings, four addressed here.

The writer census was incomplete. I grepped naming conventions, but the thing
every writer must touch is the Drizzle table symbol, and `insert(cardsToLabels)`
finds five, not three. labelRepo.create is the fourth: it creates a label on a
caller-supplied board and links it to a caller-supplied card without comparing
them. It now takes the same check, inside a transaction, so a rejected link no
longer strands the label it just created.

The check was not atomic. A card's board is a property of whatever list its
listId points at, and card.update can repoint that, so two SELECTs followed by
an INSERT left the board free to change in between. Check and insert now share
one transaction with the card rows locked (FOR UPDATE OF card), so a concurrent
move blocks rather than racing.

card.create created the card before validating its labels, so a wrong-board
label turned a corrupt success into an orphaned card plus a 500. Labels are now
resolved and checked before the card exists, and the answer is a BAD_REQUEST.

card.duplicate filtered the source card's labels against the destination board.
Those labels all carry the SOURCE board, so the filter kept everything on a
same-board copy and nothing on a cross-board one: copyLabels became a silent
no-op that still reported success. It now refuses explicitly and says why.

The fixture check compared two list ids, which differ even when both lists sit
on one board. It compares the boards.

Still open, deliberately: moving a card to another board via card.update
orphans the labels it already holds. No join row is written, so a writer guard
cannot see it, and what should happen to those labels is the same question
kanbn#568 puts to the maintainer. Filed rather than answered here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBJ4E8uw7c2Ctdyb5Dcnen
…lookups

Cage-match round 2. Carnot and Tesla independently found that the previous
commit fixed orphan-then-error in card.create, wrote the reason into a comment
as a principle, and then created the same bug in card.duplicate 1200 lines
later: cardRepo.create ran before the cross-board copyLabels refusal, so a
rejected duplicate left a card behind. Worse, the error message tells the caller
to retry with copyLabels: false, and following it produced a second card.
cardRepo.create opens its own transaction, so the router cannot roll it back;
the check has to precede it. It now does, and the labels are fetched once.

card.create treated a partial label lookup as success: if one public ID of
several did not resolve, the card was created holding fewer labels than asked
for. It now requires every requested id to resolve.

assertCardLabelBoardsMatch took Pick<dbClient, "select">, which erased the
requirement that the caller be inside the transaction doing the insert. A future
caller could have passed a plain db handle, got a passing check, and inserted
outside any lock. It now takes the transaction type, so that is a compile error
(verified: TS2345).

The lock comment claimed more than the lock does. FOR UPDATE OF card covers
card.listId changing; it does not lock lists, so a writer that retargeted a list
to another board would not be serialised against it. Nothing mutates
lists.boardId today. The comment now says which of those it covers.

Not addressed, and named in the PR body rather than half-solved: the bare Error
from the repository surfaces as 500 on the writers that do not pre-map it, and
whole-batch refusal means rows already corrupted by the open move axis will fail
unrelated batches. Both follow from enforcing a cross-table invariant in
application code, which is the design question in the filed task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBJ4E8uw7c2Ctdyb5Dcnen
Cage-match round 3, and the last: the round cap is three, and two of its stop
signals have fired (my round-2 fix generated round-3 findings, and the error
typing has now been raised in all three rounds).

Tesla: three failure modes shared one string. Unknown card, unknown label and a
genuine board mismatch all threw the same bare Error, so a missing row was
reported as a board lie and every writer that did not pre-map it surfaced a
client-correctable input as a 500. There is now a CardLabelBoardError carrying a
closed violation type, and the public add-label path maps it. A middleware could
do that once for every transport, but it changes the error path for every
procedure, so it is named rather than smuggled in here.

Tesla: the multi-card SELECT FOR UPDATE had no deterministic order, so two
overlapping bulk writes could lock the same cards in opposite order and
deadlock. It orders by card id.

Carnot: the exported assertion built inArray(_, []) on empty input. It returns
early.

Tesla: every refusal test asserted .rejects.toThrow(), which a SQL error or a
lock timeout satisfies just as well as the invariant. They now name the error
class.

Verified and REJECTED, with proof:
- Carnot read getWithListAndMembersByPublicId's nested `with: { label: ... }`
  and concluded duplicate passes undefined publicIds. The function flattens on
  the way out (labels: card.labels.map((label) => label.label)), so the shape is
  correct.
- Tesla predicted the importer could pass a pool handle inside an outer
  transaction, making the inner transaction a blind second connection. import.ts
  uses ctx.db throughout with no outer transaction, so that path does not exist.

Confirmed and NOT fixed: the FOR UPDATE lock is untested. Deleting the line
leaves all 12 tests green, which Tesla called correctly. A check whose outcome is
independent of the thing it checks is not a check, and that applies to my own
lock. Proving it needs a contention test, and closing the class properly needs
the schema-level decision already filed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBJ4E8uw7c2Ctdyb5Dcnen
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant