The prerequisite MOTIR-3501 needs and cannot supply itself: make the organization's own workspace rows readable to a user who is bound to that organization, so a count-1 predicate inside organizationsService.addMember reads the ORG's shape rather than the ACTOR's.
addMember runs inside withOrgContext, which binds app.user_id + app.organization_id and no app.workspace_id. Under that binding, a probe on origin/main @ d32892bd (org with two workspaces, actor a member of one) returned:
TRUE org workspace count = 2
SEEN under withOrgContext(founder) = 1
workspace carries five SELECT policies and not one of them admits "the bound org's rows, to a member of that org":
| policy | why it cannot fire here |
|---|---|
workspace_active | id = app.workspace_id — unbound in an org context |
workspace_membership_visible | the caller's OWN memberships — this is the arm that fires, and it is the actor's view |
workspace_public_project_read | requires coalesce(app.workspace_id,'') = '' and a public project |
workspace_system_read | app.system_admin = 'true' |
workspace_org_service_read (20260818010000) | org-keyed, but requires coalesce(app.user_id,'') = '' — the userless service context |
githubRepoRepository.ts:285-305 records the same finding independently and routes around it via github_repo, noting that widening workspace's RLS "would be a cross-tenant access change, which is not this card's to make." This card is that change, made deliberately and on its own.
One migration adding a sixth permissive SELECT policy on workspace. Permissive policies OR-combine, so nothing admitted today is admitted less:
CREATE POLICY "workspace_org_member_read" ON "workspace"
FOR SELECT
USING (
"organizationId" = current_setting('app.organization_id', true)
AND EXISTS (
SELECT 1 FROM "organization_membership" m
WHERE m."organizationId" = "workspace"."organizationId"
AND m."userId" = current_setting('app.user_id', true)
)
);
organization_membership is itself RLS'd, and org_membership_visible_active_or_own (20260613120000) admits "organizationId" = current_setting('app.organization_id') — bound here. The shipped workspace_membership_visible policy already proves a subquery against an RLS'd table works in this position.current_setting returns NULL, the comparison is NULL, the row is refused.workspace_mutate_active / workspace_delete_active (the active-workspace GUC) — org membership must not become a licence to rename or delete a workspace you are not in.Any org member — not only an owner/admin — can now enumerate the workspace rows of the org they belong to (id, name, slug, timestamps). It does not grant reach into any workspace's contents: project, work_item, workspace_membership and every scoped table keep their own policies, all of which still require the workspace GUC or an actual membership.
This is already the product's stated model — docs/decisions/organization-tier.md §4 makes the org the root tenancy tier and org membership the gate beneath which workspaces sit — and it is already what the org surfaces show: summarizeOrgFootprint and the org roster both intend the org's workspaces and merely under-deliver today. Restricting the arm to owner/admin was considered and rejected: addMember's caller is already assertOrgAdmin-gated, so the narrower policy would buy nothing at this call site while leaving the same wrong answer on the two roster surfaces below.
prisma/migrations/ adds exactly one policy, workspace_org_member_read, FOR SELECT on workspace. No existing policy is altered or dropped.withOrgContext({ userId, organizationId }), workspaceRepository.listByOrganization(orgId, tx) returns every workspace of that org — asserted against a fixture where the org has two workspaces and the acting user is a member of exactly one (the shape that currently returns 1). The same call returns 2 for a member of neither.workspaceRepository.countByOrganization(orgId, tx) under the same binding returns the org's true count.app.organization_id unset), the new arm admits nothing: a workspace-context read returns exactly the rows workspace_membership_visible alone admits — the caller's own memberships — so the policy set behaves as it did before this migration. (Amended 2026-08-26, on the record: this criterion read "returns exactly what it returns on main", which validate_work_item flagged as likely-ordering-violation on the phrase "on main". It was a BASELINE reference rather than a post-merge read, so the check is kept and the ambiguous phrasing removed — see this card's advisory-disposition comment.)TEST_DB_APP_ROLE, currentWorkerUrl() returns the app-role credentials unconditionally — there is no mode in which these assertions silently run as the owner, and fixtures use adminDb.summarizeOrgFootprint's comment — "the actor's workspaces in the org" — is corrected, since after this card it is the org's.prisma/migrations/20260527134009_add_workspace_rls/migration.sql — workspace_active, workspace_membership_visible, and the mutate/delete pair this must NOT touchprisma/migrations/20260818010000_attachment_org_service_read_arm/migration.sql — the userless org arm, and the header style to matchprisma/migrations/20260613120000_add_organization_tier/migration.sql:217 — org_membership_visible_active_or_own, what makes the EXISTS reachablelib/organizations/context.ts — withOrgContext (the binding this arm is written for)lib/repositories/workspaceRepository.ts:44 (listByOrganization), :97 (countByOrganization)lib/services/organizationsService.ts — summarizeOrgFootprint and the cross-workspace roster, the two shipped readers this silently correctslib/repositories/githubRepoRepository.ts:285-305 — the independent record of the same gapdocs/decisions/organization-tier.md §4 (org as root tenancy tier)tests/rls/policyArms.ts, tests/rls/org-context-arm-guard.test.ts — the arm inventory, its documented blind spot, and the negative control this arm makes two-namedRepo: every criterion above is discharged in motir-core (a migration, a repository read, a vitest suite). No criterion names a path in another repository.