`startSprint`'s FOR UPDATE guard cannot lock an EMPTY set — the first activation races, and the index's refusal reaches the caller as a raw Prisma error
Found and FIXED while building 11.3.6 — the fix ships in Story 11.3's PR (#1819). Logged as its own card because the defect is in shipped Story 4.4.2 code, not in the API card that surfaced it, and it is reachable from the web app as well as from /api/v1.
The defect
sprintsService.startSprint documents its one-active guard as "the AUTHORITATIVE guard is the FOR UPDATE lock on the project's active sprint INSIDE the activation transaction (TOCTOU close), with the sprint_one_active_per_project partial-unique index as the DB backstop."
That is backwards for the case that matters most. The lock is:
const locked = await sprintRepository.findActiveByProjectForUpdate(projectId, workspaceId, tx);
if (locked) throw new SprintAlreadyActiveError(...);
SELECT … FOR UPDATE over a project with no active sprint matches zero rows, so it locks nothing. On a FIRST activation — the common case — two concurrent starts both see locked === null, both fall through, and both UPDATE. What actually stops the second is the partial-unique index, at COMMIT.
And that refusal was never translated: it reaches the caller as a raw PrismaClientKnownRequestError (P2002). Every transport then renders it as an unexplained 500 — the /api/v1 status map recognises domain codes, not driver codes, and the web app has no handler for it either.
Reproduction (before the fix)
Two startSprint calls issued concurrently against a project with no active sprint, 8 rounds:
attempt 0: REJECTED name=SprintAlreadyActiveError code=SPRINT_ALREADY_ACTIVE
attempt 1: REJECTED name=PrismaClientKnownRequestError code=P2002
attempt 2: REJECTED name=PrismaClientKnownRequestError code=P2002
… (7 of 8 attempts)
7 of 8 losers got the raw error. The lock caught only 1 — which is why the API's concurrency test passed locally and failed in CI (expected [ 200, 500 ] to deeply equal [ 200, 409 ], run 30904086437, shard Vitest (2/3)).
The fix that shipped
In startSprint, the unique violation is caught OUTSIDE the transaction and re-thrown as the SAME typed SprintAlreadyActiveError the in-transaction lock raises, re-reading the winner's id so the error can name the sprint that actually won. One condition, one outcome, however the race was lost. The misleading comment is corrected in place.
completeSprint is NOT affected: it locks the project's active sprint, which by definition exists when completing one — that FOR UPDATE has a row to lock.
Why this stayed hidden
The API test that caught it ran the race ONCE. The two losing paths do not occur with equal probability, so a single round goes green on a developer machine while the common path is broken. The test now runs five rounds and clears the active slot between them, so every round races for a FIRST activation — and it fails at round 0 without the fix (verified by neutering the fix and re-running).
Follow-up worth considering (NOT done here, deliberately)
The translation makes the ERROR correct; it does not make the lock correct. A caller still loses the race at commit rather than at the guard, which is fine for this operation (one loser, one typed refusal, no partial state — the whole activation is one transaction) but would not be for an operation with side effects before the commit. A structural fix — locking the PROJECT row so activations serialize before any work — needs a new repository read and belongs to Epic 4's sprint-lifecycle story, not to the public API story that found this.
Acceptance criteria
- Two simultaneous
startSprintcalls against a project with no active sprint yield exactly one success and oneSprintAlreadyActiveError— never a raw Prisma error, never two winners. ✅ shipped - The error names the sprint that actually won. ✅ shipped
- The concurrency test drives the race repeatedly, so the common losing path is the one under test, and it fails without the fix. ✅ shipped
startSprint's comment no longer claims theFOR UPDATEis authoritative for the first activation. ✅ shipped
Context refs
lib/services/sprintsService.ts—startSprint, the lock, and the new translation.lib/repositories/sprintRepository.ts—findActiveByProjectForUpdate, and thesprint_one_active_per_projectpartial-unique index.tests/api/v1/sprint-lifecycle-routes.test.ts— the repeated-race regression test.- Surfaced by: 11.3.6. Fix ships in PR #1819.