# Roles: how S4 is built

What S4 built (plan CORE §3.4; S2 brought the core forward) and how later slices add to it. Roles are data: rows a
workspace renames and makes; code checks permissions, never roles; only the levels and the permission catalogue are
code.

## The pieces

| Piece | What it does |
|---|---|
| `Permissions::ALL` | Every key, its level (`workspace`, `platform`), group, and flags: `read` (allowed in a look-only lens), `holder_only` (only the account holder's role), `elevated` (runs the workspace: ticked only with `role.elevate`). Words: `lang/en/permission.php` |
| `StandardRoles` | Owner (`holder`: always every workspace permission), Admin, Member (`default`). The one class that names roles |
| `role` | Spatie's, with `public_id`, `standard`, `label` and `description` (per language), `level`, `is_default` (one per workspace, a partial unique index, plan SN D8), `is_account_holder` (one per workspace, a partial unique index), `requires_two_factor` (whoever holds it must use two-factor sign-in, S8; set by the role page's live toggle and the first-run wizard through `RoleEditor::twoFactor()`, S18: on for any role the actor could edit or holds, off only as editing allows, and never for the account holder's role while the platform's `security.holder_two_factor` is on: `Role::requiresTwoFactor()` and `Role::requiringTwoFactor()` read both, 25 October 2026). What `roles:sync` has granted once is `role_seeded_permission` (rows, `Role::seededPermissions()`; a jsonb list of names until plan SN D5), so it never gives back a permission an admin took away |
| `model_has_role` | Spatie's grants: a role to a membership, under the workspace that was current. The database ties each to its own workspace's role and member, `(role_id, tenant_id)` and `(model_id, tenant_id)`, and holds only memberships (a check on `model_type`); a member's grants go when the member does (plan SN slice B, 30 October 2026). `model_has_permission` the same, though Atrium grants only through roles. The two-factor rule, the role list's holders and a role's deletion read the table directly, so a grant naming another workspace must not exist |
| `roles:sync` | Every key as a row; every workspace gets the standard roles it lacks, each permission once; the account holder's role is kept whole |
| `Ceiling` | What the acting membership holds (once per request); whether it covers a role or a person; what it may tick (`offered`: held, never holder-only, elevated only with `role.elevate`) |
| `OwnAccessGuard` | Nobody changes a role they hold, or their own roles (inside a lens, the member's) |
| `RoleEditor` | The only way roles change: `create` (from another, within reach), `change` (permissions within `offered`; what the actor doesn't hold stays; the holder's role renamed only, by its holder), `delete` (a workspace's own role only), `assign` (roles within reach; others kept). Audited: `role.permissions_changed`, `role.deleted`, `membership.roles_changed` |
| `HandOverOwnership` | The holder gives the account holder's role to an active member and keeps a role of their choice. `workspace.ownership_handed_over` |
| `RolePolicy`, `MembershipPolicy` | By permission, then the editor's rules; on people, the ceiling: nobody changes, turns off or removes someone holding more |

## Adding a permission

Add the key to `Permissions::ALL` (with its group and flags), its words to `lang/en/permission.php`, give it to a
standard role in `StandardRoles` if it belongs there, name it on the route (`->middleware('can:key')`) and ask it in
the policy, then run `php artisan roles:sync` (on deploy too). The account holder's role gets it by itself.

## Pages

`role/Index` (on the list engine), `role/Show` (a form within the ceiling, else read with the reason), `role/Create`,
`person/Roles`, `role/Ownership` (from the Owner role's page), `platform/StandardRoles` (the Service, read-only: the
user's decision of 24 October 2026; platform staff roles wait for a plan that needs them).

## The guards

| Test | Proves |
|---|---|
| `Access/RoleTest` | the ceiling both ways (Kofi refused, Ama allowed); elevated permissions; the held-role guard both ways; the account holder's role; deleting; making from another; a person's roles; the ceiling on people; handing over; sync keeps the holder whole; the standard roles page |
| `Guards/PermissionGateTest` | every signed-in route names a catalogue permission (`can:key`), or is listed with its reason; no `role:` middleware; every policy method asks a permission |
| `Guards/AccessVisibilityTest` | no control disabled by permission; a page is told only the person's own permissions |
| `Guards/NoRoleNamesTest` | no role named in code; every permission named is in the catalogue |

The standard roles (`StandardRoles`): owner (the account holder's, every workspace permission), admin, member (the default), and
billing (CL-16), which holds `billing.view` and `billing.manage` and nothing else, so an organisation's bills are its owners' and
its billing people's, never its admins' (plan TB, TB1).
