(motir-core) Org add joins the org's SOLE workspace — the count-1 arm §5 and §6 never reconciled
The service arm of MOTIR-3500. Give docs/decisions/organization-tier.md §5 the count-1 arm it is missing, so an org invite into a one-workspace org lands the invitee somewhere they can work.
Today organizationsService.addMember (lib/services/organizationsService.ts:335) creates an OrganizationMembership and nothing else, at every workspace count. §5 says so deliberately ("an org-only member in zero workspaces is a valid state — e.g. a billing admin"), and tests/organizations-service.test.ts:191 asserts it. That reasoning holds only while the workspace tier offers a choice. §6 hides the tier entirely below two workspaces, so at count 1 the rule places the invitee in a tier that has no UI, no switcher, and no add-to-workspace action on the org roster.
⚠️ RE-SCOPED 2026-08-25 — the original fix direction was refused by RLS on both halves
A probe on origin/main @ d32892bd falsified the card as written (full evidence in this card's comments). Two corrections, and neither is optional:
1. The guarding READ needs a prerequisite. withOrgContext binds app.user_id + app.organization_id and no app.workspace_id, so workspaceRepository.listByOrganization(orgId, tx) returns the ACTOR's workspaces in the org, not the org's:
TRUE org workspace count = 2
SEEN under withOrgContext(founder) = 1
Uncorrected, the count-1 predicate fires in a two-workspace org whose inviting admin belongs to one of them — creating the very membership AC 3 forbids, while AC 3's own test still passes. The org-scoped read arm is now a separate prerequisite card and this card is blocked_by it. Do not re-derive a workaround: reading the count in a userless withOrgServiceWriteContext takes the guard out of the transaction it guards and misuses a helper scoped to the service-bearer billing path.
2. The WRITE needs the workspace GUC bound mid-transaction. membership_insert_active_or_bootstrap gates the INSERT on "workspaceId" = current_setting('app.workspace_id'), which the org context never binds:
INSERT under org ctx -> DriverAdapterError: new row violates row-level security policy for table "workspace_membership"
Call bindWorkspaceContext(tx, workspace.id) (lib/workspaces/context.ts:209) before the create. This is the exact downward mirror of workspacesService.addMember, which calls bindOrganizationContext(tx, …) for the upward join and documents the identical denial — read that function first and follow its shape, including its SECURITY constraint: the id must come from a TRUSTED resolution (here, the org's own workspace row), never from request input.
3. §5/§6 need a THIRD clause: whose count. The ADR's "count ≥ 2" reads as the ORG's, while the shipped gate ShellTierNav.tsx:103 counts listUserWorkspaces(session.user.id) filtered to the active org — the VIEWER's. They coincide only when the viewer belongs to every workspace in the org, which is the founder case. The amendment must say which predicate governs which clause: the org's count for §5's membership arm (this card — whether an invitee is auto-joined must not depend on the inviter's memberships) and the viewer's count for §6's disclosure (MOTIR-3502 — hiding a door the viewer cannot use is correct).
The change
Inside addMember's existing withOrgContext transaction, after the assertOrgAdmin gate and the organizationMembershipRepository.create:
- read the org's workspaces via
workspaceRepository.listByOrganization(orgId, tx)— correct only once the prerequisite arm has landed; - when there is exactly one,
bindWorkspaceContext(tx, thatWorkspace.id)and then create theWorkspaceMembership(rolemember) viaworkspaceMembershipRepository.create(..., tx)— same transaction, so an org member never exists without their workspace row; - when there are zero or two-or-more, do nothing extra — §5's asymmetry is unchanged.
Idempotency mirrors what is already there: a unique violation on the workspace membership is swallowed (the row exists, the invariant holds), exactly as ensureOrgMembership does for the upward direction. The seat resync (enqueueScaledTrackerSeatSync) stays where it is, outside the tx.
addMemberByEmail needs no change — it delegates to addMember.
Acceptance criteria
addMemberinto an org with exactly ONE workspace creates the target'sWorkspaceMembershipfor that workspace with rolemember, andorganizationsService.resolveWorkspaceAccess(target, ws)returns non-null.- Both rows are written in ONE transaction: a failure creating the workspace membership rolls back the org membership (assert no orphan
OrganizationMembershipremains). addMemberinto an org with TWO workspaces creates NOWorkspaceMembershipin either — and the fixture puts the ACTING ADMIN in exactly ONE of the two, which is the shape that fails against an actor-scoped count.tests/organizations-service.test.ts's existing "adding a user to the ORG creates NO workspace membership" case is re-pointed at a two-workspace org and still passes unchanged in substance.- Calling
addMembertwice for the same user is still idempotent-safe: the second call raisesAlreadyOrgMemberErrorand leaves exactly one workspace membership. docs/decisions/organization-tier.md§5 AND §6 each state the count-1 arm, cross-reference each other, AND name which predicate each governs (org count for the membership arm, viewer count for the disclosure arm), so the clauses no longer contradict at the boundary.- The workspace-count read is a
workspaceRepositorymethod takingtx— no inlinedb.*in the service (CLAUDE.md§ 4-layer). - The RLS binding is asserted, not assumed: a test exercises the count-1 join under the runtime (non-bypass) role, so a missing
bindWorkspaceContextfails rather than silently passing under an owner connection.
Context refs
lib/services/organizationsService.ts:335(addMember),:376(addMemberByEmail),:468(ensureOrgMembership— the upward analogue to mirror)lib/services/workspacesService.ts:573(addMember) — the shipped mid-transaction GUC bind, and the comment recording the same RLS deniallib/workspaces/context.ts:209(bindWorkspaceContext),lib/organizations/context.ts(withOrgContext,bindOrganizationContext)lib/repositories/workspaceMembershipRepository.ts:199,lib/repositories/workspaceRepository.ts:44prisma/migrations/20260810001000_tenant_root_insert_policies/migration.sql:110—membership_insert_active_or_bootstrap, the policy that refuses the writedocs/decisions/organization-tier.md§5, §6app/(authed)/_components/ShellTierNav.tsx:103,app/(authed)/layout.tsx:108— the shipped VIEWER-scoped predicate AC 5 must reconciletests/organizations-service.test.ts:191,tests/organizations-invite-by-email.test.ts,tests/rls/
Repo: every criterion is discharged in motir-core.