Type · code · Parent · MOTIR-3413 (discovery epic; no dependency edge into the finding card, so the epic is the container per log-bug.md's edge test) · Discovered in · MOTIR-3710, during that card's mandated audit of the sibling caps · Resolution · open
entitlementsService.assertCanCreateOrganization (lib/services/entitlementsService.ts:150-176) is the §4.5 org-creation gate. It is the ONE cap in that file that takes no row lock at all:
const orgs = await organizationMembershipRepository.findOwnerAdminOrgsWithSubscription(actorUserId, tx);
if (orgs.length === 0) return; // the first org — always free
const hasUncappedOrg = orgs.some((o) => o.isMeta || isScaledActive(...));
if (!hasUncappedOrg) throw new EntitlementExceededError('organizations', { limit: orgs.length });
That is a count -> compare -> create with nothing serializing it. Two concurrent creates by a user who owns zero orgs both read orgs.length === 0, both take the always-free early return, and both create an org. The user ends with two free orgs having never owned a paid one — the exact dodge the method's own comment says it exists to prevent ("otherwise a free account could spin up N free orgs to dodge the per-org caps").
This is a DIFFERENT shape from MOTIR-3710, which is why it is a separate card rather than part of it. There the org-row FOR UPDATE existed and was inert under RLS; here there is no lock to arm. MOTIR-3710's fix does not touch this method and cannot: the gate reads the ACTOR's memberships, so there is no single shared org row to lock — the anchor would have to be the user row, or a SELECT ... FOR UPDATE over the actor's organization_membership rows, which is an EMPTY SET on the very first create and so serializes nobody.
tests/entitlementsService.test.ts's fixtures, MOTIR_CLOUD=true, on origin/main @ f305d821f plus MOTIR-3710's branch (which does not touch this method). Two workspacesService.createWorkspace calls for one fresh user, raced with Promise.allSettled — no barrier needed; they interleave naturally, because each opens its own transaction and does async slug work before reaching the gate:
{ "fulfilled": 2, "rejected": [], "orgMembershipsForUser": 2, "distinctOrgs": 2 }
Both fulfilled. Zero rejections. The user holds two organizations.
Bounded at TWO, not N. Once the user owns one org, every subsequent racer reads orgs.length === 1 with hasUncappedOrg === false and both correctly reject — so the window is only the 0 -> 2 transition. Two free orgs is still two full free-tier allowances (2 x 250 work items, 2 x 3 projects, 2 x 1 workspace) for an account entitled to one, and it is reachable from a double-submitted signup or any client that fires two creates.
Cloud-only: every method in the file returns early when isCloudBilling() is false, so a self-hosted build is unaffected.
user row — SELECT "id" FROM "user" WHERE "id" = $1 FOR UPDATE inside the same transaction, before the membership count. Whatever is chosen, it must be a row that EXISTS at orgs.length === 0; the membership set does not.lockOrgRowOrRefuse shape MOTIR-3710 introduced for the three count caps. A gate that cannot serialize must refuse, not admit.SELECT ... FOR UPDATE is filtered by the UPDATE policy's USING clause, so check user's policy set for an arm the acting-user context binds — and prove it with a probe returning true, not by reading the policy.assertCanCreateOrganization actually runs in, returns true, and the pull-request body quotes it.orgMembershipsForUser: 2, and both outputs are quoted in the pull-request body.pnpm vitest run tests/entitlementsService.test.ts passes and the pull request's Vitest shard is green.lib/services/entitlementsService.ts:150-176 — assertCanCreateOrganization, the unlocked gatelib/services/workspacesService.ts:146-170 — insertWorkspaceWithOwner, the mint-own-org branch that calls itlib/services/organizationsService.ts — createOrganization, the other callerlib/repositories/organizationMembershipRepository.ts — findOwnerAdminOrgsWithSubscription, the unserialized readlockOrgRowOrRefuse helper, and the RLS-arm finding this fix must re-check for user