fix(proposals): apply a move into a sparse column instead of throwing at Insert - #3034
Conversation
#3025, from the Codex review of PR #3019. A column's stored card positions are non-contiguous whenever a middle card was deleted (delete does not renumber) or the board came from a sparse import. OperationHandlerRegistry derived the append index for a proposal `move` from max(Position) + 1, so a column holding positions 0 and 2 produced index 3, and CardService.MoveCardAsync then called List.Insert(3, ...) on a two-card list. That throws, the executor turns it into UnexpectedError, and the whole proposal rolls back at Apply - after preview had already approved it and, since #3019, released the move's source WIP slot for a following restore. Two changes, both keeping the existing positioning idiom: - The registry appends at the occupant count, which is what "one past the last card" means for the list CardService actually rebuilds, and is sparsity-independent. - CardService.MoveCardAsync clamps the insert index to the end of that list, the same Math.Min guard ColumnService.ReorderColumnAsync already uses, so no caller (API, CLI, MCP) can drive an overshooting position into an unhandled exception. Negative positions are still refused by Card.SetPosition, which runs first. Preview needs no change: with Apply able to perform the move, the projection that releases its source slot is correct again, so preview and apply agree. The wider gate question - WIP-checking create/move themselves at preview - stays tracked as #3020. Regressions: the API test drives create/delete-the-middle/propose/approve/execute end to end against a real database and fails with 500 UnexpectedError at execute without the fix; the registry test pins the append index through the real CardService; the CardService theory pins the clamp and its negative-position sibling.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Follow-up inside #3025. The append index has to match the list CardService.MoveCardAsync actually reorders, and that list is ICardRepository.GetByColumnIdAsync, which filters `!c.IsArchived`. The column navigation this handler reads (ColumnRepository.GetByIdWithCardsAsync) includes archived cards, so a plain Cards.Count over-counted a column holding archived cards and relied on the service's clamp to come back to the end of the list. An archived card is in fact the second live trigger for the same defect: it keeps its stored position so restore can return it to its original placement, so a column with one active card at 0 and an archived card at 1 has max(Position) + 1 = 2 against a one-card list. The new regression pins that case through the real CardService and fails against origin/main with ArgumentOutOfRangeException, as the sparse-position one does.
…n move Review triage for #3025 (PR #3034), both LOW and both caused by this change: - LOW-1: the Moved audit row logged the REQUESTED position. Before the clamp those were always equal; a clamped request would now write a position the board never held into a trail operators read. It logs card.Position, read after the renumber loop. The `target_column=...; position=...` shape is unchanged, so BoardMetricsService and ForecastingService still parse it. - LOW-3: a proposal move into the card's OWN column is the one path where the append index still overshoots by design (the mover is counted in Column.Cards but excluded from the list CardService reorders), so the clamp is load-bearing there rather than insurance. It used to throw; it now sends the card to the bottom, which is what the preview projection has always assumed. Pinned by a test and spelled out in the comment.
Independent fresh-context adversarial review — triageOne fresh-context read-only review ran against the worktree sources. No CRITICAL, HIGH or What it confirmed by source-tracing, worth recording because it is the load-bearing part of the Fixed (
|
Closes #3025
Summary
A proposal
moveinto a column whose stored card positions are non-contiguous threw at Apply androlled the whole proposal back.
OperationHandlerRegistry.MoveCardAsyncderived the append indexfrom
max(Position) + 1, so a column holding positions 0 and 2 (a deleted middle card - deletiondoes not renumber - or a sparse import) produced index 3, and
CardService.MoveCardAsyncthencalled
List.Insert(3, ...)on a two-card list. The executor's catch-all turned theArgumentOutOfRangeExceptionintoUnexpectedError, so the user saw a 500 at execute after theproposal had already passed approve. Since #3019, preview also released the move's source WIP slot
for that move, so a following restore was approved against capacity the failed move never freed.
Two changes, both staying inside the positioning idiom the codebase already uses:
OperationHandlerRegistryappends at the occupant count (targetColumn.Cards.Count). That iswhat "one past the last card" means for the list
CardServiceactually rebuilds, and it does notdepend on the stored positions being dense.
CardService.MoveCardAsyncclamps the insert index to the end of that list -Math.Min(dto.TargetPosition, orderedCards.Count), the same guardColumnService.ReorderColumnAsyncalready applies to column reorders - so no other caller(REST
POST /cards/{id}/move, CLI, MCP) can drive an overshooting position into an unhandledexception either. Negative positions are still refused:
Card.SetPositionruns first and itsDomainExceptionis already mapped to aValidationErrorresult.Preview needs no change. With Apply able to perform the move, the #3019 projection that releases
the move's source slot is correct again, and preview and apply agree. The wider gate question -
WIP-checking
create/movethemselves at preview - is out of scope here and stays tracked as#3020; #3012 (conflict-detector projection omitting lifecycle effects) is untouched.
Acceptance (from the issue)
(
MoveIntoASparseColumnThenRestore_IsApprovedAtPreview_AndAppliesEndToEnd).position is invalid" - not taken: with the executor fixed there is no invalid append position
left to refuse, and adding a second refusal would re-open the behaviour Validate cumulative WIP when a proposal restores a card behind other operations (#2926) #3019 deliberately
narrowed. Recorded as a decline, not a silent drop.
Verification
Ran, in the worktree
C:/wt/i3025:dotnet test backend/tests/Taskdeck.Application.Tests/Taskdeck.Application.Tests.csproj -c Release -m:1 --filter "FullyQualifiedName~OperationHandlerRegistryTests|FullyQualifiedName~CardServiceTests"- 72 passed, 0 failed.dotnet test backend/tests/Taskdeck.Api.Tests/Taskdeck.Api.Tests.csproj -c Release -m:1 --filter "FullyQualifiedName~MoveIntoASparseColumnThenRestore"- 1 passed.source edits stashed, the same API test fails with
Expected applied.StatusCode to be OK ... {"errorCode":"UnexpectedError"} but found InternalServerErrorat execute, while approve still returns 200 - the exact "passes approve, fails mid-apply" shape.
The two Application-level regressions fail with
System.ArgumentOutOfRangeException : Index must be within the bounds of the List.node scripts/check-docs-governance.mjsandnode scripts/check-doc-links.mjs- both pass(695 Markdown files, 0 broken relative links).
dotnet test backend/Taskdeck.sln -c Release -m:1- the once-per-backend-PR full run; resultposted to this PR as a comment when it finishes.
NOT run: the frontend suite and Playwright E2E (no frontend file is touched); the Postgres
integration tests (they need a live server); any manual/browser check.
Risk
The clamp changes one externally reachable behaviour:
POST /api/boards/{boardId}/cards/{cardId}/movewith a
targetPositionpast the end of the target column used to surface as a 500 and now appends.No test asserted the 500, appending is what every other overshoot path in the codebase does, and
the alternative - a 400 - would be a new rejection on a request users can make from a drag-and-drop
client whose position arithmetic disagrees with a sparse column. Positions in the target column are
renumbered contiguously by the same pass, exactly as before.