> ## Documentation Index
> Fetch the complete documentation index at: https://polar-handbook-psincraian-elastic-greider-765c1a.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Rbac

<Info>
  **Status**: Active
  **Created**: April 2026
  **Last Updated**: May 5, 2026
</Info>

## Summary

Introduce role-based access control for users on a Polar organization. Three roles ship in this iteration — `owner`, `admin`, and `member` — replacing today's implicit binary where org-level admin capability (member management, organization deletion, payout-account selection, Finance and payouts) is gated by `Account.admin_id`: the single user who is recorded as the admin on the org's Polar-side billing `Account`, today set at org creation.

All three roles are stored on the user–organization membership. `admin` and `member` are freely assignable; there is exactly one `owner` per organization. During rollout, `owner` is anchored to today's `Account.admin_id` via dual-write so the two never disagree. The end state of this work drops `Account.admin_id` and makes the role table itself the sole source of truth for ownership (a partial unique index enforces the singularity invariant; the KYC/identity-verification gate moves onto role transitions into `owner`). What `owner` adds on top of `admin` is a small set of ownership-tied responsibilities retained for KYC/KYB / UBO reasons: the legal/billing primary contact for the org, the recipient of identity-verification notifications, the sole authority to transfer ownership of the org, and a member-removal exemption (the `owner` cannot be removed from the org without first transferring ownership).

The change is mostly additive on top of the existing two-layer authorization architecture. Role lives on the user–organization membership and drives a fine-grained permission set that policies consult. Token scopes remain a per-token capability filter; policies AND a token-scope check with a role-permission check at runtime. The Stripe-side `PayoutAccount.admin_id` is a separate concept and is unaffected by this design.

## Goals

* Introduce an `OrganizationRole` enum (`owner`, `admin`, `member`) attached to organization membership.
* Move org-level admin authorization off direct `Account.admin_id` checks and onto **role permissions** evaluated at policy time.
* **Decommission `Account.admin_id`.** Once the role-based ownership has been validated in production, drop the column; the role table becomes the sole source of truth for ownership, the KYC/identity-verification gate moves onto role transitions into `owner`, and reads that previously joined `Account.admin_id` query the role instead.
* Allow multiple admins per organization (alongside the singular `owner`).
* Enforce that an organization always has at least one user with admin capability — i.e. `role ∈ {owner, admin}`. The singular `owner` always satisfies this.
* Forbid removing the `owner` from the organization; ownership transfer remains a support/backoffice task in this iteration.
* Provide back-end and front-end utilities to answer "does this user have permission X in this org?" cleanly, plus a small `is_owner(user, org)` predicate for the few owner-keyed invariants (member-removal exemption, role-validity) that are enforced outside the permission system.
* Lay foundations for additional roles (Finance, Support, Developer, …) without re-shaping the architecture.

## Non-Goals

* Invitation/accept-link flow. Members continue to be added immediately on invite; an asynchronous invitation flow is a follow-up.
* Audit trail of role changes. Will be addressed via system events as a follow-up.
* Role-as-scope-filter at token issuance. Considered and explicitly deferred — see [Roles, scopes, and permissions](#roles-scopes-and-permissions).
* Self-serve ownership transfer. Changing the `owner` of an organization remains a support/backoffice task in this iteration; a self-serve flow (current owner reassigns to a KYC-verified admin) is a follow-up.
* Modifying `PayoutAccount.admin_id` semantics. It is the legal owner of a Stripe Connect account, gates Stripe-account-level actions (onboarding link, dashboard link, delete), and is unrelated to this change. See [Relationship to `PayoutAccount.admin_id`](#relationship-to-payoutaccount-admin_id).

## Key Concepts

* **`OrganizationRole`** — new enum (`owner`, `admin`, `member`) attached to a membership. `admin` and `member` are freely assignable; `owner` is constrained to exactly one user per organization. During rollout that user is identified by `Account.admin_id`; in the end state, by a partial unique index on the role table itself.
* **`owner`** — the singular role per organization. Carries every `admin` capability plus a small set of ownership-tied responsibilities (legal/billing primary contact, identity-verification notification recipient, sole authority for ownership transfer, exemption from member removal). The user holding it must be identity-verified (Stripe KYC); the gate sits on role transitions into `owner` once `Account.admin_id` is gone.
* **Membership** — the link between a user and an organization. Today it carries no role; this design adds one.
* **`Account`** — Polar's internal financial container for an organization. Holds the credit balance, fee structure, billing identity (name, address) for invoices and receipts, and a single `admin_id`. One per org. Distinct from the Stripe-side payout account.
* **`Account.admin_id`** — today, the singular authorization signal for org-level admin actions: member management, organization deletion, payout-account selection, Finance and payout reads/writes. This design moves all those gates onto role-permission checks and, in the final phase of the rollout, drops the column. While the column still exists (phases 1–5), it is dual-written with the `owner` role so the two cannot drift; once the cleanup phase lands, the role is the only source of truth and the responsibilities the column carried (legal/billing primary contact, identity-verification notification recipient, owner-side of the Polar-for-Polar relationship, Stripe-identity-verified KYC gate) are anchored on the user holding `owner` instead.
* **`PayoutAccount`** — represents a Stripe Connect account. User-personal: each `PayoutAccount` has a single `admin_id` set at creation, immutable thereafter, representing the human whose KYC backs the Stripe account. A `PayoutAccount` can be linked to multiple organizations.
* **`PayoutAccount.admin_id`** — gates Stripe-account-level actions (onboarding link, dashboard link, delete) and the user-personal `GET /v1/payout-accounts/` listing. Unrelated to organization admin role; not changing in this design.
* **Policies** — the runtime authority for authorization decisions, organized per resource area.
* **Scopes** — describe what a *token* can do, not what a *user* can do. Sessions, personal access tokens, and organization access tokens all carry a scope set; the scope set is org-agnostic. A `scope → implied_permissions` mapping translates each scope into the fine-grained permissions it covers.
* **Permission** — fine-grained authorization unit checked at policy time. Policies require a permission; a request is allowed iff the required permission is in both `implied_permissions(token.scopes)` *and* `permissions(role_in_org)`. See [Roles, scopes, and permissions](#roles-scopes-and-permissions).

## Architecture

### Two-layer authorization (recap)

Polar's authorization model has two distinct layers:

1. **Authentication** resolves the caller into an `AuthSubject` and attaches the token's scope set.
2. **Authorization** atomically resolves the target resource, verifies org membership, and evaluates a policy function before the endpoint body runs.

Today's policies for org-level admin-only actions consult `Account.admin_id`. After this change, the same policies consult **role permissions** instead — see [Roles, scopes, and permissions](#roles-scopes-and-permissions). The shape of policies and guards does not change — only the predicate inside them.

### Roles, scopes, and permissions

Authorization at policy time consults two independent vocabularies linked by a one-way mapping:

* **Token scopes** — what *this token* is allowed to do. Org-agnostic, fixed at token issuance, the existing public capability surface (PATs, OATs, OAuth, sessions). Unchanged by this design.
* **Role permissions** — what *this user's role in this org* is allowed to do. Per-org, evaluated at runtime, internal to the RBAC layer. New in this design.
* **`scope → implied_permissions`** — a mapping in code that says: holding token scope `X` implies the fine-grained permission set that `X` covers. Bridges the two vocabularies.

Policies require a fine-grained permission. The required permission must be allowed by both vocabularies:

```
required_permission ∈ implied_permissions(auth_subject.token.scopes)
                    ∩ permissions(auth_subject.role_in(org))
```

**Bootstrapping.** Initially, the mapping is identity — for every existing scope `X`, define a same-named permission `X` and have the map send `X → {X}`. Day 1, the new layer is functionally equivalent to "use scopes directly." The first time a role-only refinement is needed, we add the fine-grained permission, list it on the role, and add it to the implied set of whichever existing scope should cover it. Existing tokens continue to imply the (now larger) permission set; they don't lose capability and don't need re-issuance.

**Why two vocabularies, not one.** Tokens are not org-qualified: a single user can be `admin` in one org and `member` in another, and one token's scope set cannot represent both states. Expressing role-as-scope at token issuance therefore either coarsens scopes to the union of roles across orgs (and policies must still gate per-org, making the scope check redundant) or churns tokens on every role change. Keeping scopes and permissions separate, and AND-ing them at policy time, is strictly stricter than either alone, with no per-request token rewriting. It also lets role permissions evolve on a different clock from token scopes — splitting a coarse permission for role purposes is a code change with no impact on existing tokens.

A defense-in-depth filter at PAT/OAT *issuance* (the issuer cannot grant scopes whose implied permissions exceed their role's) remains reasonable future work, but does not change where authorization decisions are made.

### Initial permission set and scope mapping

Most of the existing scope vocabulary is already granular enough to use as-is. The exception is `organizations:write`, which today bundles edit-settings, delete, and payout-account selection. For role purposes we split the bundle into fine-grained permissions while keeping the coarse scope intact:

| Scope (token, unchanged)                   | Implied permissions (new)                                                                    |
| ------------------------------------------ | -------------------------------------------------------------------------------------------- |
| `organizations:write`                      | `organizations:edit_settings`, `organizations:delete`, `organizations:manage_payout_account` |
| `members:write`                            | `members:invite`, `members:remove`, `members:set_role`                                       |
| `members:read`                             | `members:read` (identity)                                                                    |
| `transactions:read` / `transactions:write` | identity                                                                                     |
| `payouts:read` / `payouts:write`           | identity                                                                                     |
| every other scope                          | identity                                                                                     |

Initial role → permissions assignments:

| Role     | Permissions                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `member` | Read and write across operational resources (products, customers, subscriptions, benefits, discounts, checkout links/sessions, orders, refunds, files, events, meters, custom fields, license keys, webhooks, metrics, notifications, customer seats) and read of the member list. **Excludes** Finance (`transactions:*`, `payouts:*`, `wallets:*`, `disputes:read`), member management (`members:invite`, `members:remove`, `members:set_role`), and org management (`organizations:edit_settings`, `organizations:delete`, `organizations:manage_payout_account`). OAT management (`organization_access_tokens:*`) is gated by `organization:manage` and therefore admin-only. Billing-info edits on the org's `Account` (legal name, address, etc.) fall under `organizations:edit_settings` and are therefore admin-only by way of the org-management exclusion. |
| `admin`  | Every `member` permission **plus** the excluded set above.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `owner`  | Identical to `admin` in this iteration. Distinguished from admin by invariants (member-removal exemption, singularity per org), not by additional permissions. The `organizations:transfer_ownership` permission, if/when self-serve transfer ships, will be owner-only.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |

This keeps the day-1 footprint small: one bundle to split (`organizations:write`), one near-identity scope-to-permission map elsewhere, and three role permission sets. New permissions are added at the seam where role granularity needs to diverge from scope granularity, not preemptively.

### Where the permission check lives

A small helper inside the authorization policy layer already centralises today's `Account.admin_id` check. Three policies route through it; each gets a fine-grained permission post-cutover:

* **Member management** — invite/remove org members. Gated by `members:invite`, `members:remove`, `members:set_role` (split from the `members:write` token scope).
* **Organization deletion** — delete the org. Gated by `organizations:delete` (split from `organizations:write`).
* **Payout-account selection** — choose or change which `PayoutAccount` this org is paid out to (i.e. set `Organization.payout_account_id`). Gated by `organizations:manage_payout_account` (split from `organizations:write`). This is *not* the same as managing a Stripe Connect account; that remains gated by `PayoutAccount.admin_id`.

Replacing the body of that helper with a permission check flips all three at once. The **Finance** policies (transactions, payouts, wallets, disputes) make the same `Account.admin_id` check directly without going through the helper, and switch to the corresponding permission check too (`transactions:*`, `payouts:*`, `wallets:*`, `disputes:read` — admin-only on the role-permission side). Billing-info edits on the org's `Account` (legal name, address, etc.) are not a separate Finance gate; they piggyback on `organizations:edit_settings`, which is admin-only by role-permission assignment.

The **payout-account** policies (operations on a specific `PayoutAccount` itself: onboarding link, dashboard link, delete) check `PayoutAccount.admin_id` and **do not move**. A `PayoutAccount` can be linked to multiple organizations (a real feature), so the legal Stripe-side owner is not a per-org concept; routing those checks through org-role would let any admin of any consuming org reach into a Stripe identity that isn't theirs.

A small set of **owner-exclusive** invariants live alongside policies but aren't permissions: the member-removal exemption (the `owner` cannot be removed from the org) and the singularity rule (exactly one user carries `owner` per org). They are data-integrity rules enforced in the membership service alongside the admin-capability invariant. While `Account.admin_id` still exists, the singularity rule is also expressed by anchoring `owner` to that column via dual-write; once the column is dropped, it is expressed by a partial unique index on `UserOrganization (organization_id) WHERE role = 'owner'`.

No new dependency aliases are needed; existing scope-gated guards keep their names and required scopes. The role lookup remains a single helper: given a user and an organization, return their role (or `None` if not a member). The permission check is one step on top: `required ∈ implied_permissions(scopes) ∩ permissions(role)`. Both are consumed by policies and by service-layer invariants.

### Relationship to `Account.admin_id`

`Account.admin_id` is being decommissioned by this work, but only after the role system has been validated in production. While the column exists (phases 1–5), it is treated as the legacy storage for "who is the org's `owner`" and dual-written with the role: setting `Account.admin_id` (today via org creation and the backoffice `change_admin` flow) demotes the previous owner to `admin` and promotes the new admin to `owner` in the same transaction; conversely, the role-change endpoint refuses any transition that would put a user other than `Account.admin_id` into the `owner` role or move that user out of it. The two states cannot drift.

The cleanup phase (phase 6) drops the column and re-homes the responsibilities it was tracking:

* **Singularity invariant** — moves from "anchored on the singular `Account.admin_id` column" to a partial unique index on `UserOrganization (organization_id) WHERE role = 'owner'`.
* **KYC / identity-verification gate** — today enforced inside `account_service.change_admin` when `Account.admin_id` is mutated; moves onto role transitions into `owner` (which only the backoffice flow can perform until self-serve transfer ships).
* **Legacy joins** — `repository.get_admin_user` and any caller that fetched "the org's admin" via `Account.admin_id` switch to a query against `UserOrganization` filtered to `role = 'owner'`.
* **Notification routing and the Polar-for-Polar owner-side relationship** — re-anchored on the user holding `owner`. The user identified by these flows is unchanged; only the lookup path changes.

### Relationship to `PayoutAccount.admin_id`

`PayoutAccount.admin_id` is the legal owner of a Stripe Connect account. It is set at the time `POST /v1/payout-accounts/` creates the account and is not mutable through any code path afterwards. It continues to gate Stripe-account-level actions (onboarding link, dashboard link, delete) and the user-personal `GET /v1/payout-accounts/` listing.

Because a `PayoutAccount` can be linked to multiple organizations, its admin is necessarily a per-Stripe-account concept rather than a per-org one. This design does not move authorization for those actions. What the new role-permission system *does* govern in the payout space is the org-level decision of which `PayoutAccount` to use — i.e. setting `Organization.payout_account_id`, gated by the `organizations:manage_payout_account` permission. The conservative rule for that selection is that the caller can only switch to a `PayoutAccount` they themselves own (`PayoutAccount.admin_id == subject.id`), preserving Stripe-identity boundaries between orgs.

## Data Model

A new `OrganizationRole` enum with three members: `owner`, `admin`, `member`. The enum is attached to the user–organization membership row.

The new role attribute is required (non-null) and defaults to `member` for new memberships. Backfill maps each existing `Account.admin_id` user into the `owner` role on their membership of the owning org; everyone else is `member`. The `admin` role starts unused on existing data — it is opt-in promotion above `member`.

While `Account.admin_id` still exists (phases 1–5), service-layer validation enforces that the user with role `owner` on a membership matches `Account.admin_id` on the org's `Account`, and that the `Account.admin_id` user always carries `owner`. The role-change endpoint cannot move a user into or out of `owner`; ownership transfer flows through `Account.admin_id` mutations (today: backoffice `change_admin`, which performs the role swap as a side-effect).

After phase 6, `Account.admin_id` is gone. The singularity invariant is then enforced by a partial unique index on `UserOrganization (organization_id) WHERE role = 'owner'`, and the KYC gate is enforced on role transitions into `owner` (still only callable by the backoffice flow, which mutates the role directly instead of `Account.admin_id`). `PayoutAccount.admin_id` is unchanged throughout.

## Authorization Flow

```mermaid theme={null}
sequenceDiagram
    participant Client
    participant Authenticator
    participant PolicyGuard
    participant Policy
    participant Endpoint

    Client->>Authenticator: Request with cookie / bearer
    Authenticator-->>PolicyGuard: AuthSubject (user, scopes)
    PolicyGuard->>PolicyGuard: required_scopes ⊆ scopes ?
    PolicyGuard->>PolicyGuard: resolve org, verify membership
    PolicyGuard->>Policy: policy(auth_subject, org)
    Policy->>Policy: required_perm ∈ implied(scopes) ∩ permissions(role) ?
    Policy-->>PolicyGuard: allow / deny + reason
    PolicyGuard-->>Endpoint: AuthzContext
    Endpoint-->>Client: Response
```

The diagram changes nothing structurally from today; only the *content* of the policy step changes (permission intersection check instead of `Account.admin_id` lookup).

## API Surface

Endpoints whose authorization already routes through the central authorization helper gain permission-based behaviour automatically when that helper is updated (its body switches from an `Account.admin_id` check to the permission intersection check). This covers member listing, invite, removal, organization deletion, and finance/payout endpoints.

A net-new endpoint changes a member's role: `PATCH /v1/organizations/{id}/members/{user_id}` with a `role` body, guarded by the `members:set_role` permission. Only `admin` and `member` are accepted in the body; transitions to or from `owner` are rejected by this endpoint and flow exclusively through the ownership-transfer path (today: backoffice `change_admin`, which mutates `Account.admin_id` and the role together; post-phase-6, the same backoffice flow mutates the role directly with the KYC gate inline). The members listing response gains a `role` field. The pre-existing `is_admin` boolean on that response becomes a transitional alias for `role ∈ {owner, admin}` and is removed once the dashboard has migrated.

## Frontend

The dashboard's auth context gains role information per organization, populated from the existing per-user organizations endpoint augmented to include `role`. Two small hooks live on top: one returns the user's role in a given org, and one answers a permission question (`hasPermission(perm, org)`). The permission hook reuses the same `scope → implied_permissions` and `role → permissions` tables the backend uses, generated from a single source. The backend remains the source of truth on every request.

The Settings → Members UI uses these hooks to render a role-change control on each row (gated by `members:set_role`), to disable destructive controls when the action would leave the org without anyone holding admin permissions, and to surface the policy denial message verbatim. The `owner` row renders the role-change control disabled (with a tooltip pointing to support) and its remove-member affordance disabled. Pages currently gated on the implicit "is admin" check (Finance) move to the new permission hook.

## Invariants

### Exactly one owner per org

Enforced differently before and after phase 6. While `Account.admin_id` exists, the invariant is anchored on it: service-layer validation requires that the user with role `owner` matches `Account.admin_id`, and that the `Account.admin_id` user always carries `owner`. The role-change endpoint refuses transitions that would violate either direction; ownership transfer flows through `Account.admin_id` mutations, which perform the role swap as a side-effect inside the same transaction. After phase 6, the column is gone and the invariant is enforced by a partial unique index on `UserOrganization (organization_id) WHERE role = 'owner'`; ownership transfer flows directly through the role.

### Owner cannot be removed from the org

Enforced in the membership service: the `owner` is exempt from removal. To change ownership, the operator must first transfer it (today: backoffice `change_admin`); only then can the previous owner be demoted or removed. This is a data-integrity rule, not a permission.

### At least one user with admin capability per org

Enforced in the membership service: any operation that would leave an organization with zero users in `role ∈ {owner, admin}` is rejected. The check is evaluated against the post-mutation state inside the same transaction. The owner-non-removable invariant alone guarantees this for any org that has an `owner` (which every org does, by the singularity invariant); the explicit check is defence-in-depth.

### Read-only impersonation rejection

The recently-codified read-only-impersonation invariant ([#11303](https://github.com/polarsource/polar/pull/11303)) carries through: impersonation sessions hold no `_write` scope, and the role-mutation endpoint requires the `members:write` scope (which is what implies the `members:set_role` permission), so impersonation cannot mutate roles. No additional safeguards needed.

## Rollout

The change ships as a sequence of PRs. Each step preserves two invariants at every boundary:

1. During app/DB version skew, both old and new code can read and write the schema correctly.
2. At any point, all running code agrees which signal authorization decisions follow. Pre-cutover (Phase 3) that signal is `Account.admin_id`; from Phase 3 through Phase 5 it is the role-permission check, with `Account.admin_id` dual-written to keep the legacy column consistent for any code that still reads it. The dual-write means the two signals never disagree on the only user they both speak about (the owner). Phase 6 retires `Account.admin_id` entirely; from that point the role table is the only signal.

### Phase 1 — Additive schema, dual-write

Add the role attribute with a default of `member`. Backfill maps each `Account.admin_id` user to the `owner` role on their membership; everyone else stays `member`. The application keeps the role aligned whenever `Account.admin_id` is set (org creation) or changed (the backoffice `change_admin` flow): the previous owner is demoted to `admin`, the new admin is promoted to `owner`, in the same transaction. The owner-validity validation that guards this dual-write — "only the `Account.admin_id` user may carry `owner`, and that user always does" — ships now too, even though no caller-facing role-change endpoint exists yet, so any future or backfill write is checked.

The role-to-permissions and scope-to-implied-permissions tables (and the fine-grained permission strings they reference) also land in this phase, in code, but are not yet consumed by any policy. Shipping them early keeps Phase 3 a pure switch-flip and lets the Phase 3 verification script reference the same tables.

Authorization code does not yet read the new role attribute. Old code that doesn't know about the new attribute continues to function because the default keeps inserts well-formed.

### Phase 2 — Tighten the schema

Once every row has a value and all running code writes one, tighten the attribute to non-null. Schema-only change, no application work. Cross-table consistency between role and `Account.admin_id` continues to rest on the service-layer validation shipped in Phase 1 (Postgres CHECK can't span tables declaratively, and the column is being dropped in Phase 6 anyway, so a trigger isn't worth the trouble).

### Phase 3 — Authorization cutover

Backend authorization switches from the `Account.admin_id` check to the permission intersection check (`required_perm ∈ implied(scopes) ∩ permissions(role)`), reading the role-permissions and scope-implied-permissions tables shipped in Phase 1. The new role-change endpoint, owner-non-removable invariant, and admin-capability invariant ship in this phase. The members API response gains the `role` field; the legacy `is_admin` boolean becomes a derived alias for `role ∈ {owner, admin}`. The `PayoutAccount.admin_id` policy is unchanged.

A pre-deploy verification asserts that every pre-existing `Account.admin_id` user has the `owner` role on that org, that exactly one owner exists per org, and that no organization is left without admin capability. The deploy is gated on that check.

### Phase 4 — Frontend adopts role

The dashboard reads `role` directly via the new hooks, gates UI controls on the permission helper, and displays policy denial messages from the backend. The permission tables consumed by the hook are generated from the backend source (the same Phase 1 tables) into a TypeScript artefact at build time, so the two cannot drift. The backend continues to return both `role` and the legacy `is_admin` field, so any browser tab still on the previous bundle is unaffected.

### Phase 5 — Frontend cleanup

Remove the legacy `is_admin` field from the API. The `Account.admin_id` ↔ `owner` role dual-write stays in place through this phase — it is retired in Phase 6, alongside the column itself.

The dashboard auto-deploys on every merge but does not currently force-refresh stale tabs, so a tab from phase 3 keeps consuming `is_admin` until the user reloads. Three acceptable orderings for phase 5:

1. **Time-based** — wait long enough after phase 4 that weekly-active sessions have refreshed at least once. Cheapest path; small residual risk for very long-lived idle tabs.
2. **Bundle-mismatch prompt** — ship a "new version available — please refresh" prompt as part of phase 4. Phase 5 can then ship immediately because any stale tab self-heals.
3. **Accept the break** — ship phase 5 immediately after phase 4 and accept that any dashboard tab still on the previous bundle will see errors until the user reloads. Cheapest in engineering effort; the cost is a brief degraded experience for users with long-lived tabs.

Default to (3) — the user-visible cost is small and short-lived, and it avoids both the wait and the engineering investment in a bundle-mismatch prompt.

### Phase 6 — Drop `Account.admin_id`

After phase 5 has soaked in production long enough to trust the role system as the live authorization signal, retire `Account.admin_id`. The work splits into three steps that ship together (the read-side migration first, the schema change last) so the column is never read after the dual-write stops:

1. **Re-home the reads.** Migrate every remaining call site that still references `Account.admin_id` — `repository.get_admin_user`, the backoffice `change_admin` endpoint's storage layer, any notification or Polar-for-Polar lookup that resolved "the org's admin user" via the column, and any helpers that closed over it — to query `UserOrganization` filtered to `role = 'owner'`. The four authorization policies that historically read `Account.admin_id` are already off it (they switched to the permission check in Phase 3) so don't need further migration here. The backoffice ownership-transfer flow becomes a role mutation: it runs the existing KYC/identity-verification check on the incoming admin and then promotes them to `owner` (demoting the previous owner to `admin`) directly, instead of mutating `Account.admin_id` and letting the dual-write echo into the role.
2. **Add the role-side enforcement.** Create a partial unique index on `UserOrganization (organization_id) WHERE role = 'owner'` so the singularity invariant survives the column's removal. Move the `IdentityVerificationStatus.verified` gate, today inside `account_service.change_admin`, to live alongside role transitions into `owner`.
3. **Drop the column.** Remove `Account.admin_id` from the schema; remove the dual-write from any code path that previously kept it aligned with the role.

A pre-deploy check asserts that no production code path still reads `Account.admin_id` (a grep gate plus a database log audit covering the previous bake period) before the destructive migration runs. After this phase, the role table is the only source of truth for ownership; `Account` is a pure financial container.

## Out of Scope (Follow-Ups)

### Invitation / accept-link flow

Today, inviting a user adds them to the organization immediately. A proper invitation flow (email accept-link, expiring tokens) is a separate, additive feature that does not depend on this design.

### Audit trail via system events

Role changes, member adds, and member removes should emit system events for support and admin-side audit. The system events infrastructure recently landed and is the natural transport. Layering this on requires no schema changes from this design.

### Role-as-scope-filter at token issuance

A defense-in-depth design where personal-access tokens and organization-access tokens can only be issued with scopes whose implied permissions are a subset of the issuer's role permissions. Attractive but introduces token-staleness questions on role change and a second source of truth that must stay coherent with the role-permission table. Deferred until more roles ship and the trade-off is sharper.

## Open Questions

* **Phase 5 strategy.** Time-based wait, bundle-mismatch prompt, or accept the brief break for stale tabs?
* **Phase 6 bake time.** How long does the role system run as the live authorization signal (with `Account.admin_id` still dual-written) before we trust it enough to drop the column? Concrete signals to wait on (zero policy-denial reports tied to role lookups, audit log showing no remaining `Account.admin_id` reads in production for N days) help anchor the decision.
* **First-class "Finance" role.** RBAC.md anticipates Finance/Support/Developer roles. Should the enum reserve those values now (forward-compat) or add them when needed?
* **Permission-table source of truth.** The `role → permissions` and `scope → implied_permissions` tables live in code in this design, generated into a frontend artefact for the permission hook. Is that sufficient long-term, or do we need a richer config layer (YAML, DB) once customer-facing role customisation is on the roadmap?
