MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-3501

(motir-core) Org add joins the org's SOLE workspace — the count-1 arm §5 and §6 never reconciled

Done
Description

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 the WorkspaceMembership (role member) via workspaceMembershipRepository.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

  1. addMember into an org with exactly ONE workspace creates the target's WorkspaceMembership for that workspace with role member, and organizationsService.resolveWorkspaceAccess(target, ws) returns non-null.
  2. Both rows are written in ONE transaction: a failure creating the workspace membership rolls back the org membership (assert no orphan OrganizationMembership remains).
  3. addMember into an org with TWO workspaces creates NO WorkspaceMembership in 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.
  4. Calling addMember twice for the same user is still idempotent-safe: the second call raises AlreadyOrgMemberError and leaves exactly one workspace membership.
  5. 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.
  6. The workspace-count read is a workspaceRepository method taking tx — no inline db.* in the service (CLAUDE.md § 4-layer).
  7. The RLS binding is asserted, not assumed: a test exercises the count-1 join under the runtime (non-bypass) role, so a missing bindWorkspaceContext fails 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 denial
  • lib/workspaces/context.ts:209 (bindWorkspaceContext), lib/organizations/context.ts (withOrgContext, bindOrganizationContext)
  • lib/repositories/workspaceMembershipRepository.ts:199, lib/repositories/workspaceRepository.ts:44
  • prisma/migrations/20260810001000_tenant_root_insert_policies/migration.sql:110membership_insert_active_or_bootstrap, the policy that refuses the write
  • docs/decisions/organization-tier.md §5, §6
  • app/(authed)/_components/ShellTierNav.tsx:103, app/(authed)/layout.tsx:108 — the shipped VIEWER-scoped predicate AC 5 must reconcile
  • tests/organizations-service.test.ts:191, tests/organizations-invite-by-email.test.ts, tests/rls/

Repo: every criterion is discharged in motir-core.