# Ownership and audiences

Source: https://permdock.com/docs/concepts/ownership

Role rules as policy. Every organization keeps an owner (min, max, transferOnly), a role lists the roles it may hand out (assigns), membership kinds decide who may hold a role (for), decideRoleChange checks an assign, revoke or transfer, generated RLS enforces the counts at commit, and audiences tell the UI which surfaces a subject uses.

Scoped roles answer "what may a holder do". A B2B product also needs rules about the holders themselves: every organization keeps at least one owner, an admin hands out member and viewer but never owner, a portal contact can never become an admin, and nobody quietly demotes themselves. PermDock declares these rules on the role, checks a proposed change with `decideRoleChange`, and generates Postgres triggers that refuse a write breaking them.

```ts
const staff = { for: ["staff"], meta: { audience: "staff" } } as const;

export const policy = definePolicy(
  { permissions, roles },
  {
    scopes: {
      organization: { key: "organization_id" },
      customer: { key: "customer_id", within: "organization" },
    },
    roles: [
      role(roles.owner, ownerGrants, {
        on: "organization",
        ...staff,
        min: 1,
        assigns: ["owner", "admin", "member", "viewer", "contact"],
      }),
      role(roles.admin, adminGrants, {
        on: "organization",
        ...staff,
        assigns: ["member", "viewer", "contact"],
      }),
      role(roles.member, memberGrants, { on: "organization", ...staff }),
      role(roles.viewer, viewerGrants, { on: "organization", ...staff }),
      role(roles.contact, contactGrants, {
        on: "customer",
        min: 0,
        for: ["contact"],
        meta: { audience: "portal" },
      }),
      role(roles["platform-admin"], platformGrants, {
        meta: { audience: "platform" },
      }),
    ],
    subject: subjectFromSupabase,
  },
);
```

## Role options [#role-options]

| Option | Meaning |
| --- | --- |
| `min` | Fewest holders each instance of the role's scope keeps, default `0`. Removing the last owner is refused; deleting the whole instance (no memberships left) is not. |
| `max` | Most holders an instance may have. |
| `transferOnly` | The holder count of an instance never changes once it has holders: the role only moves from one holder to another. Creating the first holder and removing the last (which `min` guards) are not transfers. |
| `assigns` | Roles a holder may assign and revoke. Names must be declared roles; a role may list itself (`owner` assigns `owner`). |
| `for` | Membership kinds (`Membership.via`) that may hold the role. A role held through any other kind, or through a membership with no `via`, grants nothing. |
| `exclusiveWith` | Roles nobody may hold together with this one in the same instance (separation of duties, PD018). |
| `meta.audience` | The surface the role's holders use, such as `staff`, `portal` or `platform`. |

`min`, `max` and `transferOnly` count holders per scope instance, so they need a named-scope `on`; `definePolicy` throws on a global or resource role that sets them, on `max` below 1, on `min` above `max`, and on an `assigns` entry that names no declared role. `activation` stays reserved for time-boxed roles.

## Membership kinds [#membership-kinds]

The kind of a membership is a security boundary. A staff member, a guest, a portal contact, a partner and a support engineer can all hold a membership in the same organization; `for` decides which of them may hold a role:

```ts
{ scope: 'organization', id: 'T', roles: ['admin'], via: 'staff' }     // admin applies
{ scope: 'organization', id: 'T', roles: ['admin'], via: 'contact' }   // admin grants nothing
{ scope: 'organization', id: 'T', roles: ['admin'] }                   // no kind: admin grants nothing
```

The filter runs when the subject is resolved, so `decide`, `where()`, snapshots, `heldRoles` and `audiences` all see the same roles, and the generated RLS helpers apply the same check to the membership table's `via` column or the claim's `via`. A role without `for` ignores the kind. Global roles have no kind, so a role with `for` held globally grants nothing.

## Checking a role change [#checking-a-role-change]

```ts
permdock.decideRoleChange(
  {
    kind: "assign", // 'assign' | 'revoke' | 'transfer'
    role: roles.admin, // a Role leaf or a declared name
    scope: "organization", // the scope the role is held at
    id: "o_acme", // the instance
    within: undefined, // ancestor ids, for a role on a nested scope
    target: { id: "u_bob", via: "staff", roles: ["member"] }, // their membership there now
    holders: 1, // how many hold the role in the instance now
  },
  { trusted: false },
); // true only when `within` came from your own store
// { outcome: 'granted', change, role: 'owner' } or { outcome: 'denied', change, denials }
```

The actor is always the instance's own subject; nothing in the change can stand in for it. The application supplies what only its store knows: the target's current membership in the instance and the holder count. `decideRoleChange` never writes.

### Global roles [#global-roles]

A role declared without `on` is a global role, held by the principal's `roles` and not through a membership. Change one with `scope: 'global'` and no `id`; `id` is required for every named scope and a global change that carries one is denied with `validation`.

```ts
permdock.decideRoleChange({
  kind: "assign",
  role: roles["platform-support"],
  scope: "global",
  target: { id: "u_bob", roles: [] },
});
```

There is no tenant, so `within` and `{ trusted }` do not apply and the change never answers `no-membership`. The authority is the one `assignableRoles({ scope: 'global' })` lists: the declared roles with `assignable: true` (a global role is not assignable by default) whose every allow the subject holds through its global roles, or, with an `assigns` graph, exactly the roles the subject's global roles list. `exclusiveWith`, `managedBy: 'idp'` and the self-change rule behave as for a scoped role. `min`, `max`, `transferOnly` and `for` are not defined on a global role, so none of them applies. A transfer needs the subject to hold the role globally. A role held at a named scope is denied with `scope`, an undeclared name with `unknown-role` (a platform custom role is not changed through `decideRoleChange`), and the generated global-roles trigger judges the same write at commit.

Authority comes from memberships the subject holds now. An expired membership gives no tenant and no assign authority. For a role on a nested scope, the tenant and ancestors are read from the subject's own live membership at that instance (or at one of its ancestors), and a `within` that disagrees is denied with `no-membership`. When the subject holds nothing at the instance, as an organization admin assigning a role on a customer does, `within` is taken from the change only with `{ trusted: true }`, which the application passes after loading the customer's ancestors from its own store. Without either, the change is denied with `no-membership`.

| Rule | Checked on | Denial reason |
| --- | --- | --- |
| The role is declared, or is a custom role of the instance's tenant, and is held at `scope` (`global` for a role with no `on`) | every change | `unknown-role`, `scope` |
| The subject changes someone else | every change | `self-demotion` (revoke, transfer), `not-assignable-by` (assign) |
| The subject may hand the role out: it is in `assignableRoles()` for the instance's tenant and, with an `assigns` graph, a role the subject holds at the instance or an ancestor in `within` lists it (a custom role needs only the first) | assign, revoke | `not-assignable-by` |
| The subject holds the role at the instance | transfer | `not-assignable-by` |
| The target's `via` is in the role's `for` (for a custom role, in the `for` of every included role) | assign, transfer | `not-allowed-for-membership` |
| The target holds no role in the role's `exclusiveWith` (or listing it); a custom role takes the conflicts of its included roles | assign, transfer | `conflicting-role` |
| The holder count stays within `min` and `max`, and a `transferOnly` count does not move | whenever the count changes | `last-holder`, `max-holders`, `transfer-only` |

`countHolders(memberships, { scope, id, role })` reads the number from the same `MembershipSource` (its `list`), counting each principal with a live membership of that instance and role once, so the count comes from the source the subject resolves from instead of a second query; it answers `undefined` when the source cannot list or the read fails. An unknown or malformed `holders` fails closed: a role with `min`, `max` or `transferOnly` is denied with `holders: null` in the detail. Assigning a role the target already holds leaves the count alone; transferring to someone who already holds it counts as a removal. `granted` names the role that authorised the change (the transferred role for a transfer, the assigning role with an `assigns` graph, `null` otherwise). Pair it with a permission check for the action itself:

```ts
"use server";
export async function changeRole(input: unknown) {
  const permdock = await getPermDock();
  permdock.assert(permissions.member.assignRole);
  const change = RoleChangeSchema.parse(input); // boundary data: validate it
  const within = await db.scopes.ancestors(change.scope, change.id); // never from the request
  const target = await db.memberships.find(
    change.scope,
    change.id,
    change.targetId,
  );
  const holders = await db.memberships.count(
    change.scope,
    change.id,
    change.role,
  );
  const decision = permdock.decideRoleChange(
    { ...change, within, target, holders },
    { trusted: true },
  );
  if (decision.outcome === "denied") return { denials: decision.denials };
  await db.memberships.apply(change); // the triggers re-check at commit
  return { changed: true };
}
```

On the snapshot-backed client `decideRoleChange` always denies with `unsupported`: the snapshot carries no holder counts, so role changes are server decisions. The UI offers `assignableRoles()` instead; a snapshot answers `assignableRoles({ scope: 'global' })` with an empty list.

## Who may hand out what [#who-may-hand-out-what]

Without `assigns`, [`assignableRoles()`](/docs/concepts/custom-roles) keeps its ceiling rule: the declared assignable roles the subject holds, plus those whose every allow the subject holds. Once any role declares `assigns`, the graph is authoritative: the subject hands out exactly the roles its held roles list, whether or not it holds their permissions (an admin who lists `contact` links portal contacts without holding `quote.accept`), and a role nobody lists is assignable only by a `meta.manageRoles` holder. `RoleSource.assignable(tenant)` narrows either list.

## Rank and audiences [#rank-and-audiences]

`heldRoles()` and `assignableRoles()` are ordered by the `assigns` graph: a role comes before the roles it assigns, then declaration order. `owner` ranks above `admin` because owner assigns admin, so a role chip or a members table shows rank without a hard-coded list. `heldRoles({ scope, id })` narrows to the memberships of one scope instance. Without an `assigns` graph the order is unchanged.

`audiences()` returns the distinct `meta.audience` values of the roles held in the active tenant, in rank order, and `snapshot.audiences` carries the same list to the client:

```ts
permdock.audiences(); // ['staff'] for a member of T
permdock.tenant("B").audiences(); // ['portal'] for the same user, a contact in B
```

Use it to pick a layout or a landing page (the staff app, the customer portal, the platform console) and to show a "switch to portal" link to someone who has both. It decides nothing: every page still checks its permissions.

## In the database [#in-the-database]

`permdock rls generate` turns the rules into database objects ([CLI](/docs/cli/rls)):

* The `permitted_<scope>_ids` helpers, and the `exists` check on a resource role's membership table, count a role with `for` only on rows whose `via` column (or claim entry) is listed; a table without a `via` column holds it for nothing.
* A deferred constraint trigger per scope membership table, or on each table of the scope's `fromTable` / `fromJunction` membership sources, checks `min` and `max` at commit. A transfer done as two statements in one transaction passes; a commit that leaves an organization without an owner fails with SQLSTATE `23514` and hint `last-holder`.
* Statement triggers over transition tables refuse a statement that changes a `transferOnly` role's holder count.
* `permdock_can_assign(p_role, p_scope_id)` answers the `assigns` graph for the application's own policies on its membership tables. A global role that lists a global role in `assigns` assigns it with a null `p_scope_id`; no scoped role can.
* With `rls.assignments`, a trigger on each membership table, on the global-roles table `rls.roles` and on an invitations table you list refuses a client write that assigns, changes or removes a role the caller may not assign there, by the `assigns` graph or, for a custom role, by what the caller may hand out. A global role is checked at no instance, so only a global role whose `assigns` lists it passes. `ownRole: 'refuse'` also refuses a client write to the caller's own row, as `decideRoleChange` does. Writes by the table owner, a `security definer` function or a backend role are trusted, and `permdock doctor` PD064 warns on a guarded table no migration puts the trigger on ([CLI](/docs/cli/rls#assignment-triggers)).

`decideRoleChange` gives the user a reason before the write; the triggers catch the write that skipped it (a script, an admin console, a second code path).

### Moving a transfer-only role [#moving-a-transfer-only-role]

The transfer-only trigger looks at each statement, so write a transfer in one of two shapes, both inside one transaction:

* **One statement** that swaps both rows, so the holder count of the role never moves:

  ```sql
  update organization_users
  set role_id = case user_id when $1 then $3 else $4 end -- $3: the old owner's new role, $4: owner
  where organization_id = $5 and user_id in ($1, $2);     -- $1: current owner, $2: new owner
  ```

* **Demote first, then promote.** Demoting the only holder takes the count to zero, which is not a transfer; promoting the new holder brings it back to one. The `min` check waits for the commit, so the organization is never seen without an owner. Promoting first refuses the statement with hint `transfer-only`, because the count goes from one to two.

supabase-js and PostgREST run each request in its own transaction, so a two-statement transfer from the client commits after the demotion and fails `min` with hint `last-holder`. Put the transfer in a database function and call it with `rpc()`; the function body runs in one transaction. `tests/integration/src/rls-ownership.test.ts` runs all three orders.

## Catalog, Cloud and doctor [#catalog-cloud-and-doctor]

The [catalog](/docs/concepts/wire-formats) lists each role's `min`, `max`, `transferOnly`, `assigns`, `for`, `exclusiveWith` and `audience`, so PermDock Cloud applies the same rules in its assignment checks. `permdock doctor` PD026 warns on a scope whose roles set no `min`; set `min: 0` on one of them to record that none has to stay, as the customer scope above does ([doctor](/docs/cli/doctor)).

## Why [#why]

Ownership rules are the part of role management every B2B product writes by hand, and gets wrong in the same places: the last owner leaves and the organization is orphaned, an admin promotes themselves, an invite flow gives a contractor an admin role. CentraKit, the field-service product this page's example comes from, enforces "at least one owner" with a commit-time check and "you cannot change your own role" in every assignment function. Declaring the rules on the role puts them in the one place `decide`, snapshots, the catalog, the Cloud and the database already read.

* **The graph, not the ceiling, when you draw one.** "You cannot hand out what you do not hold" is a good default and stays the default. It cannot express "admins link portal contacts" (admins do not accept quotes) or "only owners appoint owners" (admins may hold every permission an owner has). `assigns` states the intent directly, and ranking falls out of it for free.
* **Membership kind is a security boundary.** Shopify collaborators, Slack guests, Figma and Linear external members, and Microsoft GDAP partners all stop external people from reaching admin by the kind of membership, not by the role name. Dropping the role when the kind does not match, at resolution and in the RLS helpers, means a mis-provisioned membership fails closed everywhere at once.
* **Counts are checked at commit.** A transfer is two changes (promote one, demote the other); checking after each statement would refuse every transfer or require a special API. A deferred constraint trigger sees the final state, and an instance with no memberships left is being torn down, so deleting an organization still works. `transferOnly` cannot be deferred (transition tables are statement-level), which is why it refuses per statement and treats "through zero" as a transfer.
* **No switch to leave the triggers out.** The triggers enforce what the role declares, as a backstop for every write path. An option that kept `min` or `transferOnly` in the application only would let a script or an admin console write what `decideRoleChange` refuses, with nothing in the policy to say so. An application whose transfer promotes before it demotes rewrites that one function as above; one that is not ready leaves `transferOnly` off the role until it is, and keeps `min`.
* **Own rows are opt-in in the database.** `decideRoleChange` always refuses a change that targets the actor, but a trigger cannot tell a self-service flow the application meant to allow, such as leaving an organization, from a self-promotion. `assigns` already stops a caller from giving themselves a role they may not hand out; `ownRole: 'refuse'` closes the rest, a platform admin granting themselves another global role, for applications whose own-role changes all go through trusted functions.
* **The application supplies the holder count.** PermDock never reads another user's memberships at decide time: the store is the application's, and a count read inside `decide` would be a hidden network call. Failing closed on a missing count keeps a forgotten argument from becoming a silent pass; the trigger is the backstop.
* **Ancestors come from a held membership or the application's store.** The tenant decides which assign authority applies, and a nested change names its tenant through `within`. Read from the request, a `within` naming the caller's own organization would let a lead in one organization hand out roles on another organization's team. Reading it from the subject's live membership closes that, and `{ trusted: true }` covers the one case a membership cannot: an organization admin acting on a scope they do not belong to, where only the application's store knows the parent. It matches `trusted` on `decide`: the caller marks the data it loaded itself.
* **Audiences are metadata, not permissions.** A layout switch keyed on role names drifts every time a role is added. `meta.audience` names the surface once per role, and `audiences()` stays a read of held roles, so it can never grant anything.
