Developer docs
Reference

Permissions matrix

Role by action, including the project-level roles.

docs/PERMISSIONS.md

Who may do what. Two systems that answer different questions — see Hierarchies.

Organization roles

internal below means owner, admin or member.

Actionowneradminmemberagencyclient
See every project in the org✅✅✅❌❌
See a project they are a member of✅✅✅✅✅
Create a project✅✅✅❌❌
Edit a project / set budgets✅✅✅project roleproject role
Delete a project✅✅✅❌❌
Manage project membership✅✅❌❌❌
Change the plan / open the billing portal✅✅❌❌❌
Start an agent run✅✅✅❌❌
Approve an agent run✅✅✅❌❌
Compose a post / propose a date for it✅✅✅project roleproject role
Edit or withdraw their own proposal✅✅✅✅✅
Edit somebody else's post✅✅✅❌❌
Schedule a post (commit a time to the publisher)✅✅✅❌❌
Publish now✅✅✅❌❌
Log out of every sessionown accountown accountown accountown accountown account

Two of these are worth saying out loud:

A member cannot manage project membership. Inviting an outside contractor into a project is a tenancy decision, not a workflow one.

An agency cannot approve. An agency contributor can see a run on their project, read its proposal and its cost — and cannot sign it off. An agency does not approve its own agent's spend.

An external contributor proposes; it takes an internal role to schedule. A client or agency user on a project can compose a post and put a suggested date on the calendar. What they cannot do is commit that date: their post always lands draft, their date always lands on the post's proposed_publish_at, and the publisher reads neither — it selects on a variant whose status is scheduled, which is the one field an external role has no route to write. The suggestion becomes a real schedule when an approval lands and scheduleApprovedPost promotes it.

That is what makes this safe to offer at all. Non-negotiable #3 says nothing reaches a third-party platform without a recorded approval; here that holds structurally rather than by convention, because the gate is the only path between what a client can write and what the publisher can read. Two consequences worth stating:

  • The org role is only the first lock. assertCanWriteProject still runs, so a client invited to a project as a viewer can read the calendar and propose nothing. Making them a contributor is the deliberate act that lets them contribute.
  • An external contributor cannot decide on their own submission, even when a client_review stage names them as an approver. Propose-and-approve in two clicks would make the gate a formality for exactly the person it exists to check. This binds external roles only — an internal author who is also a named approver is an ordinary small-team arrangement and still works.

Project roles

Applied after the organization role has decided the person can see the project at all.

Actionleadcontributorviewer
Read the project, its budgets and its team✅✅✅
Edit the project✅✅❌
Set or clear a budget✅✅❌
Approve anything❌❌❌

Approval is never a project role. It is an organization-level decision, because the person answering for the spend is not the person doing the work.

How refusals are shaped

SituationResponseWhy
Not in your organization404403 would confirm the id exists, letting a caller probe for real resources
In your org, but you are not a project member404Same reason. Read and write give the identical answer, so the write path cannot be used as an oracle either
A member of the project, but your project role is read-only403You already know it exists — hiding it now would just be confusing
Your organization role cannot do this at all403
Your plan does not include the feature402With the plan named and what to upgrade to

The 404-vs-403 split is not stylistic. Getting it wrong turns every authorization check into a resource-enumeration endpoint.

E12 policy-based RBAC (in progress)

The static tables above are the current authoritative source of truth. Alongside them, E12 introduces a policy engine (apps/api/src/modules/authz/) that lets an organization define custom roles and permission sets without changing route code:

  • Role — per-org role definition. Five built-in roles (owner/admin/member/agency/client) are seeded on org creation and cannot have their permissions edited (v1); their display name can be changed. Custom roles have no such restriction.
  • Permission — a static catalogue of dotted keys (posts:read, billing:manage, …). See apps/api/src/modules/authz/permissions.ts for the full list.
  • UserRoleAssignment — one user may hold multiple roles; the effective permission set is the union of every role's keys, plus the wildcard * for the built-in owner.

The POST /billing/checkout endpoint is the first route migrated from a hard-coded role check to requirePermission("billing:manage") as a demonstration. Every other role check is left in place with a TODO(rbac) marker until a follow-up sweep migrates them one endpoint at a time.

The legacy user.role column is still populated on user creation for backward compatibility and is NOT dropped in this release.