Add the move-place-between-groups endpoint #61

Merged
rob merged 3 commits from feat/move-place-endpoint into main 2026-08-05 07:29:17 +00:00
Owner

Implements Vikunja task 71, the last of the Places epic: PATCH /api/places/{id}/group.

  • Dual authorisation: RequireGroupCapabilityForPlace(MayEditPlaces) on the route covers the source group; the handler makes a second, explicit call to the shared AuthorizeGroupCapabilityAsync for the destination named in the body — the same shape DeleteGroupMember already uses for its own conditional second check. Task 148's safety net only ever sees the source capability; the destination check is proved by this PR's own tests.
  • A move is checked against Version exactly like PUT, on one atomic UPDATE (ADR-0048).
  • A place may cross the personal-group boundary in either direction — the destination check alone already restricts that to the personal group's own owner, so no special-case code was needed (ADR-0018's own gap, closed).
  • Moving to the current group is not special-cased — it's an ordinary successful write, like a no-op PUT.

Reasoning in full in ADR-0055.

Full solution build, dotnet test and dotnet format --verify-no-changes run clean locally with the pinned SDK.

Implements Vikunja task 71, the last of the Places epic: `PATCH /api/places/{id}/group`. - Dual authorisation: `RequireGroupCapabilityForPlace(MayEditPlaces)` on the route covers the source group; the handler makes a second, explicit call to the shared `AuthorizeGroupCapabilityAsync` for the destination named in the body — the same shape `DeleteGroupMember` already uses for its own conditional second check. Task 148's safety net only ever sees the source capability; the destination check is proved by this PR's own tests. - A move is checked against `Version` exactly like `PUT`, on one atomic `UPDATE` (ADR-0048). - A place may cross the personal-group boundary in either direction — the destination check alone already restricts that to the personal group's own owner, so no special-case code was needed (ADR-0018's own gap, closed). - Moving to the current group is not special-cased — it's an ordinary successful write, like a no-op `PUT`. Reasoning in full in [ADR-0055](docs/adr/0055-move-a-place-between-groups.md). Full solution build, `dotnet test` and `dotnet format --verify-no-changes` run clean locally with the pinned SDK.
Add the move-place-between-groups endpoint
All checks were successful
CI / build (pull_request) Successful in 2m28s
f2e6051981
rob left a comment

Verdict: mergeable

Dual authorisation checked by mutation: deleting the destination AuthorizeGroupCapabilityAsync call reddened 5 tests; weakening the source route filter from MayEditPlaces to Member reddened PatchMovePlace_ViewerInSourceButEditorInDestination_RespondsWith403ProblemDetails — both halves genuinely bite, restored cleanly afterwards.

Destination non-disclosure: PatchMovePlace_ANonMemberDestinationAndANonexistentDestination_RespondIndistinguishably compares status, content-type, problem body fields, trace-id shape and headers — a real indistinguishability proof, not just a status-code match.

Version/xmin check: confirmed the atomic compare-and-swap holds cross-operation, not just same-method — raced MoveToGroupAsync against a concurrent UpdateAsync on the same row and version 8 times, exactly one write always won and the row ended up consistent with the winner. The PR itself doesn't ship this cross-operation race test (only UpdateAsync's own multi-writer race is covered); worth adding given MoveToGroupAsync is new and shares the same WHERE xmin = @ExpectedVersion shape, but not blocking — the SQL is identical in kind to the already-proven UpdateAsync case.

Personal-group argument: verified against the tests as written — third-party-into-someone-else's-personal-group is refused 404, and the same owner can move in both directions. The "Owner hides a place from other shared-group members" scenario named in the brief isn't exercised with a genuinely multi-member shared group, but the mechanism is identical to Editor-delete (ADR-0039), which already permits the same visibility loss without other members' consent — the ADR's argument holds, it's just not tested with a second member present.

Atomicity: single UPDATE, confirmed one-group invariant via GET /api/groups/{id}/places for both groups in the happy-path test. Didn't re-check GET /api/places after a move in the PR's own suite, but ListForCallerAsync's group_id-scoped join makes this mechanically the same as the already-tested group-scoping cases.

Task 148's safety net: confirmed /api/places/{id}/group is picked up by GroupScopedEndpointAuthorisationTests' route enumeration and carries only the source GroupCapabilityMetadata, matching ADR-0055 §"What task 148's safety net sees" exactly.

OpenApiDocumentTests route list and docs/adr/README.md are both accurate. Full solution build, dotnet format --verify-no-changes, and dotnet test (728 tests) all clean on the pinned 10.0.100 SDK.

Verdict: mergeable Dual authorisation checked by mutation: deleting the destination `AuthorizeGroupCapabilityAsync` call reddened 5 tests; weakening the source route filter from `MayEditPlaces` to `Member` reddened `PatchMovePlace_ViewerInSourceButEditorInDestination_RespondsWith403ProblemDetails` — both halves genuinely bite, restored cleanly afterwards. Destination non-disclosure: `PatchMovePlace_ANonMemberDestinationAndANonexistentDestination_RespondIndistinguishably` compares status, content-type, problem body fields, trace-id shape and headers — a real indistinguishability proof, not just a status-code match. Version/xmin check: confirmed the atomic compare-and-swap holds cross-operation, not just same-method — raced `MoveToGroupAsync` against a concurrent `UpdateAsync` on the same row and version 8 times, exactly one write always won and the row ended up consistent with the winner. The PR itself doesn't ship this cross-operation race test (only `UpdateAsync`'s own multi-writer race is covered); worth adding given `MoveToGroupAsync` is new and shares the same `WHERE xmin = @ExpectedVersion` shape, but not blocking — the SQL is identical in kind to the already-proven `UpdateAsync` case. Personal-group argument: verified against the tests as written — third-party-into-someone-else's-personal-group is refused 404, and the same owner can move in both directions. The "Owner hides a place from other shared-group members" scenario named in the brief isn't exercised with a genuinely multi-member shared group, but the mechanism is identical to Editor-delete (ADR-0039), which already permits the same visibility loss without other members' consent — the ADR's argument holds, it's just not tested with a second member present. Atomicity: single `UPDATE`, confirmed one-group invariant via `GET /api/groups/{id}/places` for both groups in the happy-path test. Didn't re-check `GET /api/places` after a move in the PR's own suite, but `ListForCallerAsync`'s `group_id`-scoped join makes this mechanically the same as the already-tested group-scoping cases. Task 148's safety net: confirmed `/api/places/{id}/group` is picked up by `GroupScopedEndpointAuthorisationTests`' route enumeration and carries only the source `GroupCapabilityMetadata`, matching ADR-0055 §"What task 148's safety net sees" exactly. `OpenApiDocumentTests` route list and `docs/adr/README.md` are both accurate. Full solution build, `dotnet format --verify-no-changes`, and `dotnet test` (728 tests) all clean on the pinned 10.0.100 SDK.
# Conflicts:
#	tests/PlaceMark.Api.Tests/Documentation/OpenApiDocumentTests.cs
Pin cross-operation concurrency and personal-group hiding
All checks were successful
CI / build (pull_request) Successful in 2m21s
516424091f
Author
Owner

Merged main (PR #60, account deletion). Addressed both review gaps:

  • Added MoveToGroupAsync vs UpdateAsync cross-operation concurrency tests. First tried a symmetric Task.WhenAll race — it passed even with MovePlaceToGroup's version guard deleted, because UpdateAsync's own guard alone kept exactly one write succeeding regardless of the broken one (the PR #58 shape). Replaced with two deterministic tests that force one write to commit while the other is genuinely blocked on the row lock, so each pins down its own operation's guard specifically. Verified each reddens when its guard is deleted and passes once restored.
  • Added the multi-member personal-group hiding scenario: a shared group's Owner, with an Editor and a Viewer also members, moves a place into their personal group. Confirmed both other members' listings lose the place while keeping a second, untouched one, and the mover's personal listing gains it.

On that second test: I don't think the behaviour is wrong. An Editor of the shared group could already delete the same place outright (ADR-0039), so this grants no new destructive power — same visible effect to the other members either way. Worth flagging for whoever builds the WebUI though: group deletion gets a confirmation naming the place count (per CLAUDE.md), and this move gets none, despite having a similar "things disappear from your shared view" effect. Left as a UX note rather than an API change.

Merged `main` (PR #60, account deletion). Addressed both review gaps: - Added `MoveToGroupAsync` vs `UpdateAsync` cross-operation concurrency tests. First tried a symmetric `Task.WhenAll` race — it passed even with `MovePlaceToGroup`'s version guard deleted, because `UpdateAsync`'s own guard alone kept exactly one write succeeding regardless of the broken one (the PR #58 shape). Replaced with two deterministic tests that force one write to commit while the other is genuinely blocked on the row lock, so each pins down its own operation's guard specifically. Verified each reddens when its guard is deleted and passes once restored. - Added the multi-member personal-group hiding scenario: a shared group's Owner, with an Editor and a Viewer also members, moves a place into their personal group. Confirmed both other members' listings lose the place while keeping a second, untouched one, and the mover's personal listing gains it. On that second test: I don't think the behaviour is wrong. An Editor of the shared group could already delete the same place outright (ADR-0039), so this grants no new destructive power — same visible effect to the other members either way. Worth flagging for whoever builds the WebUI though: group deletion gets a confirmation naming the place count (per `CLAUDE.md`), and this move gets none, despite having a similar "things disappear from your shared view" effect. Left as a UX note rather than an API change.
rob left a comment

Verdict: mergeable

Both order-forced cross-operation race tests discriminate correctly: removed MovePlaceToGroup's version clause and only the move-side test reddened (update-side unaffected); restored, removed UpdatePlace's clause and only the update-side test reddened (move-side unaffected). Neither masks the other. The ordering is genuinely lock-forced, not timing-dependent — the "locking" transaction holds a row-level lock via an uncommitted raw UPDATE, and whichever real statement queues behind it is guaranteed by Postgres's EvalPlanQual re-check to evaluate its WHERE against the post-commit row regardless of exactly when its command reaches the server; ran both eight times back to back with no variance. Correctly diagnoses the same PR #58 shape (Task.WhenAll symmetric races can pass vacuously when only one side's guard is real).

PatchMovePlace_OwnerOfASharedGroupMovingAPlaceIntoTheirPersonalGroup_HidesItFromTheOtherMembers is a real Owner/Editor/Viewer group, with a second, untouched place as a control — confirms both Editor and Viewer lose the moved place while retaining the surviving one, and only the mover sees it in the personal group afterwards. Matches the claim.

Agree with the reasoning on hiding-by-move: MayEditPlaces already grants unconfirmed hard delete (ADR-0039, ADR-0005) with no notification to other members, and this app has no notification mechanism anywhere else either (no email, no audit trail per CLAUDE.md's ops posture) — so the silence isn't a new gap specific to this endpoint. If anything a move is less destructive than the delete it's compared against: the place survives, recoverable by the mover, where a delete is gone for everyone. The comparison to "group deletion confirms with a count" in the handler's remarks is the wrong-shaped comparison to worry about, since that guards group deletion, not the single-place delete this is actually equivalent to — but the remark reads as an aside pointing at future WebUI work, not as claiming architectural cover from that ADR, so it doesn't weaken the conclusion.

Merge conflict was exactly the one file, keep-both, alphabetically consistent; diffed the new head against the previously-reviewed commit and the only other changes present are main's account-deletion feature (PR #60), unrelated to this endpoint. PlaceEndpoints.cs, PlaceRepository.cs, MovePlaceRequest.cs and ADR-0055 are byte-identical to the version already reviewed. Full solution build, dotnet format --verify-no-changes, and dotnet test (769 tests) clean on the pinned 10.0.100 SDK.

Verdict: mergeable Both order-forced cross-operation race tests discriminate correctly: removed `MovePlaceToGroup`'s version clause and only the move-side test reddened (update-side unaffected); restored, removed `UpdatePlace`'s clause and only the update-side test reddened (move-side unaffected). Neither masks the other. The ordering is genuinely lock-forced, not timing-dependent — the "locking" transaction holds a row-level lock via an uncommitted raw `UPDATE`, and whichever real statement queues behind it is guaranteed by Postgres's `EvalPlanQual` re-check to evaluate its `WHERE` against the post-commit row regardless of exactly when its command reaches the server; ran both eight times back to back with no variance. Correctly diagnoses the same PR #58 shape (`Task.WhenAll` symmetric races can pass vacuously when only one side's guard is real). `PatchMovePlace_OwnerOfASharedGroupMovingAPlaceIntoTheirPersonalGroup_HidesItFromTheOtherMembers` is a real Owner/Editor/Viewer group, with a second, untouched place as a control — confirms both Editor and Viewer lose the moved place while retaining the surviving one, and only the mover sees it in the personal group afterwards. Matches the claim. Agree with the reasoning on hiding-by-move: MayEditPlaces already grants unconfirmed hard delete (ADR-0039, ADR-0005) with no notification to other members, and this app has no notification mechanism anywhere else either (no email, no audit trail per CLAUDE.md's ops posture) — so the silence isn't a new gap specific to this endpoint. If anything a move is less destructive than the delete it's compared against: the place survives, recoverable by the mover, where a delete is gone for everyone. The comparison to "group deletion confirms with a count" in the handler's remarks is the wrong-shaped comparison to worry about, since that guards group deletion, not the single-place delete this is actually equivalent to — but the remark reads as an aside pointing at future WebUI work, not as claiming architectural cover from that ADR, so it doesn't weaken the conclusion. Merge conflict was exactly the one file, keep-both, alphabetically consistent; diffed the new head against the previously-reviewed commit and the only other changes present are `main`'s account-deletion feature (PR #60), unrelated to this endpoint. `PlaceEndpoints.cs`, `PlaceRepository.cs`, `MovePlaceRequest.cs` and ADR-0055 are byte-identical to the version already reviewed. Full solution build, `dotnet format --verify-no-changes`, and `dotnet test` (769 tests) clean on the pinned 10.0.100 SDK.
rob merged commit ebe868f2c0 into main 2026-08-05 07:29:17 +00:00
rob deleted branch feat/move-place-endpoint 2026-08-05 07:29:17 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!61
No description provided.