Approving a 15-item plan 500s — materialize runs in ONE interactive transaction on Prisma's DEFAULT 5s budget, and wires blocked_by edges ONE round trip per link
POST /api/plans/cmt3j4ilz002hi6ph3z89vo7p/approve returned 500 on every attempt. The plan is 15 add proposals with 27 blocked_by edges — the expansion of MOTIR-3298. The cards were created directly instead; the plan is still planned and must be DECLINED or it will duplicate them.
What actually happened
fly logs -a motir-core, four failures, all PrismaClientKnownRequestError P2028:
2026-08-21T22:49:55Z ⨯ P2028: Transaction API error:
Unable to start a transaction in the given time.
2026-08-21T23:01:49Z ⨯ P2028: Invalid `prisma.workItemLink.createManyAndReturn()` invocation:
A query cannot be executed on an expired transaction. The timeout for this
transaction was 5000 ms, however 5033 ms passed since the start of the transaction.
meta: { modelName: 'WorkItemLink', operation: 'query', timeout: 5000, timeTaken: 5033 }
at async Object.createIfAbsent (...)
(repeated 23:02:03 timeTaken 5034, and 23:02:41 timeTaken 5030)
Both halves of Prisma's default budget bit: the first attempt exhausted maxWait (2 000 ms) waiting for a connection, the next three exhausted timeout (5 000 ms) mid-body, each dying in the blocked_by link pass — the last big batch of round trips.
The route is not at fault. app/api/plans/[id]/approve/route.ts maps every typed plan error to 4xx and re-throws the rest; P2028 is not one of them, so it surfaces as a 500 with no actionable body.
Root cause — two independent contributors
1. approvePlan opens its transaction with no budget, and the helper it uses cannot take one.
lib/services/plansService.ts calls withWorkspaceContext(...) around the lock → gate → materialize → onboarding-rename sequence. lib/workspaces/context.ts:86 hardcodes db.$transaction(fn) with no options parameter at all — unlike its sibling withWorkspaceServiceContext (line 250), which already accepts TransactionBudget and applies { timeout: options.timeoutMs, maxWait: options.maxWaitMs }. So the one context every tenant-scoped write uses is the one that cannot raise its budget.
The type and the precedent already exist: TransactionBudget (context.ts:307) documents the 5 000 / 2 000 defaults and says raising them should be "a visible, argued decision at the call site", and projectRunnerGroupService.ts:72 ships SYNC_TX_BUDGET = { timeoutMs: 30_000, maxWaitMs: 10_000 }.
2. The link pass is one network round trip PER EDGE.
workItemLinkRepository.createIfAbsent (line 139) takes a single WorkItemLinkUncheckedCreateInput and calls tx.workItemLink.createManyAndReturn({ data: [data], skipDuplicates: true }) — an array API invoked with an array of one. 27 edges is 27 sequential awaits against Neon from Fly iad, on top of 15 × (allocate number + create + status update + body rewrite + auto-relate + record revision). The transaction's own error text names the better fix first: "Consider increasing the interactive transaction timeout or doing less work in the transaction."
Fix contributor 2 before reaching for contributor 1. A batched insert removes ~26 round trips and is the change that makes the budget adequate rather than merely larger.
Scope note — this is a CEILING, not a one-off
15 items is not a large plan. add_plan_items is documented to accept up to 4 096 proposals per call and the shipped generator emits trees layer by layer, so any plan above roughly a dozen items with a dense edge graph is on the wrong side of a 5-second budget. Nothing warns the author: the plan closes to planned cleanly, validate_work_item --planId returns valid: true, and the failure appears only when a person presses Approve.
Acceptance criteria
withWorkspaceContextaccepts an optionalTransactionBudgetand applies it exactly aswithWorkspaceServiceContextdoes; omitting it keeps today's Prisma defaults, verified by a test that the option-less call passesundefined.workItemLinkRepository.createIfAbsentgains a BATCH form that inserts N links in ONEcreateManyAndReturnwithskipDuplicates, andmaterialize'sblocked_bypass uses it; the PR body states the round-trip count before and after for a 27-edge plan.approvePlanpasses an explicitTransactionBudgetwith a comment arguing the numbers, in the shapeSYNC_TX_BUDGETset.- An integration test approves a plan of at least 15 adds and 25 edges and asserts it commits — it must FAIL on
mainbefore the fix, and the PR body says so. - P2028 maps to a typed 503-or-409 response naming the plan, not a bare 500: an approve that times out tells the caller the transaction was too large, and the message names the item count.
- The existing approve integration tests stay green and none is weakened.
Context refs
motir-corelib/workspaces/context.ts—withWorkspaceContext(line 86, no options),withWorkspaceServiceContext(line 250, has them),TransactionBudget(line 307).motir-corelib/services/plansService.ts—approvePlan'swithWorkspaceContextcall, andmaterialize(line 747) with its Pass 2 edge wiring.motir-corelib/repositories/workItemLinkRepository.ts—createIfAbsent, line 139.motir-corelib/services/projectRunnerGroupService.ts—SYNC_TX_BUDGET, the shipped precedent to copy.motir-coreapp/api/plans/[id]/approve/route.ts— the error map that has no P2028 arm.motir-coretests/integration/plans/— the approve suites the new test joins.- Plan
cmt3j4ilz002hi6ph3z89vo7p— the failing input, stillplanned; DECLINE it, its 15 cards exist as MOTIR-3381 … MOTIR-3395.