3.1.5 Board mutation service — cross-column move = workflow transition; in-column = rank
Estimate: 22m · Depends on: 3.1.3, 2.2.4
The write side that makes "moving a card = a workflow transition, not a board-local write" real. boardsService.moveCard(boardId, workItemId, target, ctx) where target = { toColumnId, beforeId?, afterId? }, in one transaction:
1. Resolve the target column’s status. Read the column’s board_column_status mapping. If the card’s current status is already in the target column’s mapped set (a within-column drop, OR a drop into a multi-status column that already contains the card’s status) → no transition, only a rank change. Otherwise pick the target status: the column’s mapped status; for a multi-status column, the one ordered first by status.position (Jira’s rule). A drop onto an unmapped target (a column with no statuses, or an unmapped status) is rejected with a typed error.
2. Cross-column move → transition. Delegate the status change to the validated path issuesService.updateStatus (Story 2.2.4), which runs workflowsService.canTransition under the project's policy mode. An illegal transition raises — caught + re-raised as a typed IllegalBoardMoveError (carrying from/to status + the reason) so the 3.1.6 route maps it to HTTP 409 and the 3.2 UI snaps the card back. The issue’s status is the single source of truth; the board stores nothing about placement.
3. Rank within the column. Recompute work_item.position between the beforeId / afterId neighbors via the existing lib/workItems/positioning.ts fractional-index helper (finding #18) — the same mechanism the tree ordering uses. A pure within-column reorder (step 1 found no status change) does ONLY this, attempting no transition. (Board rank is the global work_item.position; the backlog-rank semantics deepen in Epic 4 — this story does not fork a board-local rank.)
Returns the updated BoardCardDto + the applied status + the resolved column, so the UI can reconcile its optimistic update. One service method = one transaction; typed errors (IllegalBoardMoveError, UnmappedColumnTargetError, not-found) live in lib/boards/errors.ts.
Out of scope: the drag-drop UI + optimistic update + snapback animation (Story 3.2 — this is the server contract it calls); WIP-limit rejection on over-limit drops (Story 3.3).
Acceptance criteria
boardsService.moveCardresolves the target column’s status, delegates cross-column moves to the validatedissuesService.updateStatuspath (2.2.4), and updateswork_item.positionfor rank — all in one transaction.- A within-column move (or a drop into a column that already maps the card’s status) changes ONLY
position; no transition is attempted. - An illegal cross-column transition under
restrictedpolicy raises a typedIllegalBoardMoveErrorand leaves the issue status + position unchanged (the snapback contract). - A drop onto an unmapped target raises a typed
UnmappedColumnTargetError. - Multi-status target columns resolve to the first status by
status.position(Jira rule); the resolution is unit-tested. - Vitest (real Postgres) covers: legal cross-column move (status + rank change), illegal move rejection (no mutation), pure in-column reorder (rank only), unmapped-target rejection, and workspace scoping.
Context refs
- Story 2.2.4 —
issuesService.updateStatus+workflowsService.canTransition(the validated transition path the move delegates to; do NOT re-implement transition validation) lib/workItems/positioning.ts— the fractional-index rank helper (finding #18) reused for in-column reorderlib/services/workflowsService.ts— policy mode +canTransitionmotir-core/CLAUDE.md— one-service-method-one-transaction; typed errors inlib/<domain>/errors.ts