11.3.6 The sprint LIFECYCLE moves — `POST /api/v1/sprints/{sprintId}/start` and `/complete`, proven under REAL concurrency
The two state transitions that make a sprint a cadence rather than a container: activate it, and close it out with its unfinished work carried over. These are the only read-derived writes in 11.3 — both guard on the project's CURRENT active sprint before writing — so they are the endpoints where a concurrency test is the deliverable rather than a nicety.
Thin adapters over sprintsService.startSprint and completeSprint, returning the sprint schema 11.3.4 pins.
The concurrency guard is already SHIPPED — expose it, do not rebuild it
startSprint closes the TOCTOU window itself: a friendly findActiveByProject pre-check 409s early, and the authoritative guard is findActiveByProjectForUpdate — a SELECT … FOR UPDATE on the project's active sprint INSIDE the activation transaction — with the sprint_one_active_per_project partial-unique index as the DB backstop. completeSprint locks the same row the same way. So this card must not introduce a count-then-write or check-then-write guard at the route: the route parses, calls one service method, and maps the typed error. The story's completeness clause says this explicitly, and a route-level guard would be both redundant and racy.
What the card owes is the PROOF, at the HTTP boundary: two simultaneous starts, one 200 and one 409 with a typed { code, error } — never two 200s, never a raw Postgres unique-violation leaking as a 500. A serial test does not test this; the test must drive genuine concurrency against real Postgres and accept every legitimate ordering of the two outcomes.
Two shipped behaviours the API inherits — name them, do not re-decide them
startSprintidempotently provisions a scrum board before activating (boardsService.createBoardwhen the project has none), deliberately OUTSIDE the activation transaction because provisioning is independent and idempotent. The API inherits that side effect; it does not add, move or suppress it.startSprintstamps the immutable baseline —committedIssueCountandcommittedPoints— from the sprint's issues at activation. After this call those fields stop being null, which is the observable difference between a planned and a started sprint on every read in this story.
What to build
POST /api/v1/sprints/{sprintId}/start— body isStartSprintInput:startDate(defaults to now),endDate, and the optional inlinename/goaledits the shipped start dialog performs inside the activation transaction. An action as a sub-path of the resource, matchingPOST /api/v1/work-items/{key}/archive(11.2.10) rather than inventing a new verb convention.POST /api/v1/sprints/{sprintId}/complete— body isCompleteSprintInput:carryOverTodefaults to'backlog', or{ sprintId }to append the unfinished issues to an existing planned sprint in the same project. Done-category issues always stay on the completed sprint — that is the historical record, and the response must not suggest otherwise.
Both are sprints:write and sprint-admin gated, exactly as 11.3.5's pair.
Acceptance criteria
- Both endpoints exist, declare
scope: 'sprints:write', and refuse aread-only token with 403; asprints:writetoken whose owner is not a sprint admin is refused 403 with a distinguishablecode. - Two simultaneous starts against the same project: exactly one succeeds; the loser receives a 409 with a typed
{ code, error }— driven concurrently against real Postgres, with both orderings accepted, and asserting the loser's body is the mapped domain error rather than a 500 or a raw driver message. - Starting a sprint that is not
plannedis a 422 carrying the shippedSprintNotStartableError; an invalid window is a 422; each is raised by the service and has a deliberate row in the v1 status map. - After a successful start the sprint reads back
state: 'active'with non-nullcommittedIssueCount/committedPoints(andcommittedPoints: nullonly when the sprint was wholly unestimated) — the baseline is observable through the API, not just in the database. - Completing with the default destination leaves done-category issues on the sprint and returns the unfinished ones to the backlog in rank order; completing into a planned sprint appends them there; completing into a non-planned or cross-project sprint is a typed 422, not a 500.
- No route calls Prisma, opens a transaction, or performs any check-then-write of its own — asserted by 11.1's shipped tree-wide guard plus a reading of this card's diff.
- Unit tests ship with the routes; every new file holds the ≥90% per-file coverage floor.
- ONE PR against
motir-core.
Context refs
lib/services/sprintsService.ts—startSprint(the pre-check,findActiveByProjectForUpdate, the board provisioning, the baseline snapshot) andcompleteSprint(the same lock, the carry-over).lib/repositories/sprintRepository.ts—findActiveByProjectForUpdate, and thesprint_one_active_per_projectpartial-unique backstop.lib/dto/sprints.ts—StartSprintInput,CompleteSprintInput,CarryOverDestination,assertSprintTransition.lib/api/v1/errors.ts— whereSprintAlreadyActiveError(409) /SprintNotStartableError(422) /SprintWindowInvalidError(422) each need a deliberate row.motir-core/CLAUDE.md§ concurrency — lock the row before a read-derived update, and translate a lost race into a typed domain error.- Blocker: 11.3.4. Action-path precedent: 11.2.10. Parent story: 11.3.