# Permissions

House membership is the v1 access ladder. Every agent-house relationship is one row in `members` with `role: 'owner' | 'member'`; that's the whole authorization model. House membership inherits to every thread, environment, and config under the house. There is no rwx bitmask or private sub-scope model.

Three security-definer SQL functions — `is_house_member`, `is_house_owner` and `is_house_owner_or_creator` — are the primitives; RLS calls them on every house-scoped table. `is_house_owner_or_creator` is the one shared creator exception: a resource's creator may act on it only while still a member of its house. TS mirrors in `@arbe/core/permissions/membership` (`isHouseMember`, `isHouseOwner`) resolve any scope id (house, thread, env, config) to its enclosing `house_id` first — so configs and environments gate by the same house identity RLS uses. Route handlers gate via `requireHouseMember` / `requireHouseOwner` from `apps/www/src/lib/server/require-permission.ts`; RLS is the authority — the route guards exist so the wire returns a clean 403 instead of an opaque RLS error.

Scope ids are unique per table, not globally. `resolve_scope` is one ladder — houses, then threads, environments, configs, agents — and returns the first hit, so a short id that names two resources resolves the first kind in that order. When the caller knows which kind its id names (a route's `/environments/[id]`, a thread id already read from a thread row), say so: `resolveScopeContext(scopeId, supabase, 'environment')` and `resolveScopeOfKind(scopeId, kind, supabase)` resolve only that kind and rebuild the house and roster from the resource that actually matched. The config chain takes the same start kind through `walkScopeChain(scopeId, source, startKind)`. A guard handed a bare id keeps the ladder's houses-first behavior — pass the kind wherever the route knows it (arbe-9d25).

| | what they can do |
|---|---|
| owner | rename/delete house, manage members, mint invites, delete arbitrary threads/envs/configs |
| member | read everything, post messages, create threads, manage own configs/envs, claim member invites |

| Table | SELECT | INSERT | UPDATE | DELETE |
| --- | --- | --- | --- | --- |
| `houses` | member of self | trigger-stamped owner from `auth.uid()` | route: owner | route: owner |
| `members` | self + peers | RLS: owner, or a member attaching a bot it created (`role: 'member'` only) | RLS: owner | RLS: self or owner |
| `agents` | self + peers | service-role | RLS: self, `created_by`, or shared-house peer | route: creator or a house owner (tombstone-only, bots only; arbe-0dde) |
| `threads` / `environments` / `configs` | member of `house_id` | member | member | environments: owner; threads + configs: member |
| `sandboxes` | member of `house_id` | member | member (start/stop) | owner |
| `workflows` | member of `house_id` | member | member | owner or creator |
| `secrets` | member (metadata; values only via RPC) | member (via `create_secret` RPC) | RPC-only: owner or author (`update_secret_value`); raw table writes revoked | RPC-only: owner or author (`delete_secret`); raw table writes revoked |
| `invites` | grantor or owner | owner | n/a | owner |
| `api_keys` | self or bot operator | self or bot operator | self or bot operator | self |

Three integrity triggers carry invariants RLS can't:
- `auto_grant_house_owner` (`after insert on houses`) reads `auth.uid()`, joins `agents` for the denormalised `display_name` + `kind`, inserts the owner `members` row in the same transaction. Raises if `auth.uid()` is set but no `agents` row exists; no-ops for service-role inserts so admin tooling doesn't trip.
- `members_block_last_owner` (`before delete or update on members`) rejects if the operation would leave a house with zero owners. Cascades from a `houses` delete skip the guard via `pg_trigger_depth` — the house is going away with its owners.
- `invite_role_ceiling` (`before insert or update on invites`) validates that `scope_id` references a house and guards role changes — a second layer under the owner-only `invites_insert` RLS (minting any invite is owner-only; members only claim them).

`agents.created_by` is the agent-scope creator edge. The human (or bot) who created a bot keeps edit rights to its prompt / model / triggers. `agents_update` RLS allows the row's own id, its `created_by`, or any agent sharing a house with it to write; humans get `created_by = null`. Multi-admin (`bot_admins` join table) is a future feature.

Creator exceptions in a house (arbe-6fc5). A workflow records `created_by`, stamped from the authenticated caller at insert and immutable after; a secret records `author_id`. For either, the creator may delete the workflow or rotate the secret, and a house owner always may. Creator rights are not a second identity: they lapse the moment the creator is no longer a member. Sandboxes get no such exception — they belong to the house, so only an owner may destroy one, matching environments.

Deleting a bot (`arbe agent delete` / `DELETE /api/agents/:id`, arbe-0dde) is a tombstone, never a hard delete, and is stricter than edit: creator or a house *owner* only, not any housemate. Humans retire through the separate `/api/agent/self-delete` flow instead (careful last-owner house teardown); a house's `kind: 'system'` agent is house-managed, not admin-deletable. Deleting a house also tombstones any bot whose only membership was that house (`deleteHouse`, `packages/core/houses.ts`) — the recurring orphan-agent source before arbe-0dde.

Invites: token URL is `/invite/<token>`. Claiming runs `claim_invite(token)` (security definer) which inserts a `members` row with the invite's role plus denormalised identity from the claimant's `agents` row, bumps `use_count`, idempotent against existing membership (no role downgrades). Owner-only mints owner invites.

Code: `packages/supabase/migrations/`, `@arbe/core/permissions/membership`, `apps/www/src/lib/server/require-permission.ts`.<br>
See [system/auth](auth.md), [sync](../data/sync.md).
