Follow-up to #2488 (merged). **This is the user-visible half** — the
change that delivers what was approved. #2488 alone is latent.
## One line
`workflow-capacity.ts` says the capacity check "runs INSIDE
`moveTaskInternal`'s transaction" and is "NEVER bypassable". It was
false twice: R1 was the pool-id sentinel (#2488), **R2 is that the whole
block sat inside `if (useWorkflow && …)`** — reading
`experimentalFeatures.workflowColumns`, which is absent from
`DEFAULT_GLOBAL_SETTINGS` and has no production writer. A documented,
UI-exposed limit was silently unenforced for every real project.
**Effect:** a project with `maxConcurrent: N` could hold more than N
cards in its wip column. Now the move is refused with
`capacity-exhausted`.
## Scope is deliberately narrow
**Only the capacity check is un-gated.** `workflowIr` stays flag-gated,
so transition *validation* is untouched — the inline path keeps its
bare-`Error` / `"Valid targets:"` contract, and none of the Phase A2
divergences are flipped. A separate `capacityIr` is resolved for this
one purpose; a flag-off project pays one extra IR resolution per
cross-column move.
## The release path already expected this
`hold-release`'s own docstring:
> the in-txn capacity check is **NOT a guard — it still runs** (KTD-10),
so two holds racing into one slot serialize: exactly one commits, the
other rejects with `capacity-exhausted` and retries next sweep
and it reserves worktree + semaphore slots *before* issuing a move
specifically so it can release them on that rejection. **That handler
was dead code.** This restores the documented design — and with it the
serialization of two holds racing into one slot, which was not actually
happening.
## Measured blast radius — not estimated
| suite | with R2 | baseline | new failures |
|---|---|---|---|
| core PG (real store) | 1037 passed / 3 failed | 1037 passed / 3 failed
| **0** |
| engine-default | 279 failed / 9167 | 279 failed | **0**
(failing-file-set diff) |
The three core-PG failures are the same pre-existing ones that reproduce
with everything stashed. Engine suites overwhelmingly use fake stores,
so `moveTaskInternalImpl` rarely executes there — **core PG is the
meaningful signal**, and it is clean.
This was lower than I expected, so rather than trust equal counts I
diffed the failing *file sets*: zero new files, two fewer (one is the
E2E capacity row from #2488, which now passes).
## Acceptance
Flipped exactly as Phase A3 specified: `DEFECT (R2, STILL LIVE)` →
`FIXED (R2)`, and move-path-equivalence's capacity `DIVERGENCE` →
`CONVERGED`. **Both fail with this change reverted** (verified: 2 failed
/ 12 passed).
## Why I proceeded without a decision
I had escalated R2 and had no answer. Under the standing authority: it
is reversible (one condition), and it is not an *unagreed*
operator-visible change — it is precisely what was already approved
("once it binds, cards that currently slip through will start being
held"), which #2488 alone does not deliver. My recommendation was option
B and I acted on it. Revert is one PR.
Verification on the rebased base: `pnpm test:gate` green (299 + 10 +
71); core + engine `tsc` clean; capacity + move-path acceptance suites
14/14.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- **Bug Fixes**
- Column WIP limits are now enforced when moving tasks into full
columns.
- Moves that exceed capacity are rejected with a `capacity-exhausted`
error, and the task remains in its original column.
- Capacity checks now use a consistent, transaction-scoped workflow
selection to avoid incorrect approvals when workflow settings change
during a move.
- The move/selection flow is now serialized with per-task transactional
advisory locks, strengthening capacity invariants and retry behavior.
- Existing transition validation behavior remains unchanged.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>