# Permission matrix

This is the §8.1 specification for roles × actions × resources. The
executable table is `ACTION_TABLE` in `libs/core/src/policy.ts`. Controllers
and socket handlers call `can(actor, action, resource)`; they must not
re-implement checks.

Actor role is always the **server-fetched** `workspace_members.role` for the
current workspace, never a client-supplied role.

## Roles (rank)

| Role | Rank | Notes |
|---|---|---|
| `owner` | 5 | Unique. Can delete the workspace and transfer ownership. |
| `admin` | 4 | Settings, invites, archive, moderate any message. |
| `member` | 3 | Default. Create channels, start calls, schedule events. |
| `multi_channel_guest` | 2 | Several channels; cannot create channels or start calls. |
| `single_channel_guest` | 1 | One channel. `isOutsideGuestScope` is true outside it. |
| `bot` | 0 | Integration actor. No interactive admin. |

`atLeast(role, min)` is rank comparison.

## Resource context

| Flag | Meaning |
|---|---|
| `isOwnResource` | The actor is the author/organiser/assignee as defined per action. |
| `isOutsideGuestScope` | Single-channel guest acting on a channel they are not in. |

## Matrix

Y = allowed. Own = only with `isOwnResource`. Scope = denied when
`isOutsideGuestScope`.

| Action | owner | admin | member | multi_channel_guest | single_channel_guest | bot |
|---|---|---|---|---|---|---|
| workspace:read | Y | Y | Y | Y | Scope | — |
| workspace:update_settings | Y | Y | — | — | — | — |
| workspace:delete | Y | — | — | — | — | — |
| member:invite | Y | Y | — | — | — | — |
| member:remove | Y | Y | — | — | — | — |
| member:change_role | Y | Y | — | — | — | — |
| channel:create | Y | Y | Y | — | — | — |
| channel:read | Y | Y | Y | Y | Scope | — |
| channel:update | Y | Y | Y | — | — | — |
| channel:archive | Y | Y | — | — | — | — |
| channel:post | Y | Y | Y | Y | Scope | — |
| message:edit_own | Own | Own | Own | Own | Own+Scope | — |
| message:edit_any | Y | Y | — | — | — | — |
| message:delete_own | Own | Own | Own | Own | Own+Scope | — |
| message:delete_any | Y | Y | — | — | — | — |
| message:pin | Y | Y | Y | — | — | — |
| task:create | Y | Y | Y | Y | Y | — |
| task:read | Y | Y | Y | Y | Y | — |
| task:update | Own or admin+ | Own or admin+ | Own | Own | Own | — |
| task:assign | Y | Y | Y | — | — | — |
| task:update_status | Own or member+ | Y | Y | Own | Own | — |
| task:delete | Y | Y | Y | Own | Own | — |
| event:create | Y | Y | Y | — | — | — |
| event:read | Y | Y | Y | Y | Y | — |
| event:update | Own or admin+ | Own or admin+ | Own | Own | Own | — |
| event:invite | Own or admin+ | Own or admin+ | Own | Own | Own | — |
| event:rsvp | Y | Y | Y | Y | Y | — |
| event:delete | Own or admin+ | Own or admin+ | Own | Own | Own | — |
| call:start | Y | Y | Y | — | — | — |
| call:read | Y | Y | Y | Y | Y | — |
| call:join | Y | Y | Y | Y | Y | — |
| call:end | Y | Y | Y | Y | Y | — |

## Single-channel guests

Subtlety the table cannot fully express: a guest may hold workspace
membership and still be outside a given channel. `channel:read` /
`channel:post` / `workspace:read` take `isOutsideGuestScope`. Services must
set that flag from **channel membership**, not from role name alone.

Call history is additionally scoped to calls the actor participated in —
`call:read` being guest-level does not make another member's call log public.

RSVP / join mean "answer something addressed to you"; the service still
checks the actor is an attendee or invitee.

## Channel membership vs workspace role

Posting also requires a `channel_members` row. Policy is not a substitute for
that check. RLS scopes by `workspace_id`; cross-resource writes (pin message
X into channel Y) still fetch-and-assert the relationship.
