Skip to content

runbooks / feature

Roles

Run every runbook once per role, and prove what each role sees: the screen, a read-only view or a denial.

Most apps have more than one kind of user. An admin issues invoices, an editor fixes them, a manager approves them, and a visitor with no session gets sent to the sign-in page. Role tracking makes each runbook prove what every one of them sees, from one set of steps.

The problems

Every test logs in as admin

A suite usually has one test account, and it is often the one with every permission. The flow passes for that account. Nobody checks what an editor sees on the same screen, or what happens when a visitor with no session opens the URL.

Nobody can say who may do what

The answer is spread across role enums, guards, middleware, policies and the conditionals that hide a button. No file lists it, so a reviewer can check "managers approve invoices, editors don't" against nothing but the code.

Nobody proves the denials

An editor who must not delete an invoice is a requirement too. When no test opens the screen as an editor, a missing guard ships, and the route still counts as covered because the admin test touched it.

A permission change leaves stale tests behind

When editors lose delete, every test and spec that says editors delete is wrong. Nothing points at them. They fail weeks later, or keep passing because they log in as admin.

The same login gets different access per record

A manager edits the clients of their own team and only views the others. A test that opens one client proves one of the two. The rule depends on the record, not on the user, so no role name captures it.

One copy of the flow per role

Testing each role means a copy of the test per role, and every change to the flow has to reach every copy. Most teams stop after the first two roles.

The solution role tracking provides

Who may do what, in one file

roles.json lists each role, the roles it inherits from, and its actions on each capability/feature key. It sits next to the runbooks, so a permission change shows up in the pull request as a diff anyone can read, and git log of the file is the history of every permission. Setup fills it from your code and marks each entry it could only guess, so you start from what the code does. When the rules already live in a document, /runbooks:roles:generate reads it, as a spreadsheet, a PDF, a CSV or plain text, and proposes the roles, contexts and permissions it describes. See Permissions.

One runbook, every role

The steps are written once. Expected: holds the result common to every role, and a line per role says where a role differs: read-only, hidden, denied or partial. A run repeats the steps once per role, each with its own login. When a step ends denied for a role, the steps below it become not-applicable for that role instead of a row of failures.

Denials are results

A denied line is checked like any other Expected: the run fails when the editor gets in. Coverage counts each role and key as one unit, and a key a role has no access to counts as covered only when a step proves the denial.

Access per record, in the same run

A context names a question about the record, such as how the user relates to the client, and its cases are the answers. On the keys of a context, a role can list different actions per case. A runbook on those keys runs once per case for each role, and a step line such as manager in vip-outside: read-only states what the role gets in one case. See Contexts.

A permission change points at the runbooks it reaches

/runbooks:permissions:manage lists the runbooks of the role, and of every role that inherits from it, that the change reaches, and marks them for review. /runbooks:list --review finds them, and only /runbooks:update clears the mark, after you confirm each role's Expected. A stale runbook can't hide behind a green run.

A missing account is not a failure

Each role logs in with the env var names in accounts. A role with no account yet gets no-account on its steps, and the other roles still run, so you can register a role before its test user exists.

Without and with role tracking

QuestionWithoutWith role tracking
The roles a run checksThe test account, often an adminEvery role in the runbook's roles
Where permissions are writtenAcross guards, policies and UI conditionalsroles.json, reviewed in the pull request
A role that must be deniedUntested unless someone writes that testA denied, hidden or read-only line the run checks
Coverage of a routeCovered once any test touches itCounted per role and key, denials included
After a permission changeStale tests fail later, or neverThe runbooks it reaches get a review mark
A role with no test accountA failed loginno-account, and the other roles still run
Access that depends on the recordChecked on whichever record the test opensOne pass per case, such as own client and other client

An app with one kind of user doesn't need it. With tracking off, everything behaves as before, and roles: [admin] in a runbook still names the account the steps log in as.

How it works

Turn tracking on

Setup asks "Track roles?" right after the stack. It recommends yes when it finds signs of roles: keys in accounts, roles lists and As: lines in runbooks, or a role enum, guard or policy file in the code. On a yes, it reads the role enums, guards, middleware, policies, permission helpers and seeds, and the security scopes and 403 responses of upstream.schema when the project has one. Then it shows the roles it found and the matrix it deduced, with each entry marked code, contract or guess. In a project that is already set up, settings asks the same questions:

❯ /runbooks:settings roles

Turning tracking on gives every runbook roles: all and a review entry with the reason roles-enabled. The command shows a dry run first and changes the runbooks only after you approve. /runbooks:update then writes the role lines of each runbook. This version cannot turn tracking off.

The registry

roles.json maps each role to a matrix. A matrix key is <capability>/<feature> or <capability>/*, with the names of the runbook frontmatter, and each key lists actions. The four base actions are view, create, edit and delete, and actions adds the project's own. A role with no entry for a runbook's key has no access to it. On a key of a context, the entry can map each case to its own actions. Keys, lookup, inheritance and the origin of each entry are on the Permissions page, and cases on the Contexts page.

{
  "version": 1,
  "actions": ["approve"],
  "roles": {
    "anonymous": { "description": "No session", "permissions": {} },
    "editor": {
      "description": "Edits invoices",
      "found": ["src/auth/roles.ts:12"],
      "permissions": { "billing/invoices": ["view", "create", "edit"] }
    },
    "manager": {
      "description": "Billing manager",
      "inherits": ["editor"],
      "found": ["user"],
      "permissions": { "billing/invoices": ["view", "create", "edit", "approve"] }
    }
  }
}

editor

view, create, edit

inherits

manager

own entry: + approve

manager inherits the matrix of editor. Its own entry for billing/invoices replaces the inherited one, so that entry lists every action manager has on the key, approve included. An own entry can also take an action away.

anonymous is the role with no session. It is always registered, and it cannot be deleted, renamed, given an account or given parents. The login of every other role lives in accounts.<role> in the config, as env var names, never values. A role without an account is valid, and its ui runs end in no-account. The history of the permissions is the git log of roles.json.

Manage the roles

Five commands manage the roles. roles:list only reads the registry. The other four show the diff before they write and commit by your policy. Contexts and permissions have their own commands, on the Contexts and Permissions pages.

CommandWhat it does
roles:listLists the roles with their parents, account, runbooks, pending reviews and last results. With a name, it shows the role's effective permissions and where each one comes from.
roles:createRegisters a role with its description, parents, account and permissions, and offers to mark every roles: all runbook with role-created.
roles:updateChanges the description, parents or account, or renames the role in every runbook line and fixture folder that names it.
roles:deleteRemoves the role from the registry, the config and the runbooks. Heirs either keep a copy of what they inherited or lose it.
roles:generateReads a document about who may do what and merges the roles, contexts and permissions it describes into roles.json. See Permissions.
❯ /runbooks:roles:create support-agent
❯ /runbooks:permissions:manage support-agent
❯ /runbooks:roles:list support-agent # check the effective permissions

What each role should see

A runbook lists the roles that run it in roles, or writes all for every registered role, anonymous included. A Roles: line limits one step to some of them. The Expected: line holds the result common to every role. After it comes one line per role that sees something else, starting with the role's name and an outcome.

---
id: billing.issue-invoice
capability: billing
feature: invoices
roles: all
---

### S02. The invoice list shows the new invoice

Parents: S01
Page: `/billing/invoices`

Expected: the list shows invoice "INV-001" with a "Delete" button.
- editor: read-only. The "Delete" button does not appear.
- anonymous: denied. Redirects to /signin.
OutcomeMeaning
allowedThe common Expected applies. A role with no line is allowed.
read-onlyThe role sees the screen and cannot act.
hiddenThe control or the screen does not appear.
deniedA 403 page, a redirect or an error.
partialThe role acts with limits, and the text after the outcome names them.

api steps use only allowed, denied and partial. On api, denied means 403 for a signed-in role and 401 for anonymous, and the line states the status and the body. These lines replace the separate 403 and 401 steps, and the As: line of each step. While a runbook waits for its roles-enabled review, As: stays valid, and /runbooks:update turns it into role lines.

Runs per role

A run goes through every role of the runbook, inside each role through every case, and inside each case through every viewport. Each role logs in once, and each case builds its record once per role. In headless, headed and script modes the agent logs out and logs in with the next role's account. In guided, chrome and prompt modes you sign in as each role before its pass. The report keeps one state per step, role, case and viewport.

❯ /runbooks:run billing.issue-invoice --mode headless # every role
❯ /runbooks:run billing.issue-invoice --role editor,viewer # only some roles, a partial run
❯ /runbooks:run clients.edit-client --case vip-team,regular # only some cases, a partial run

Two states exist only with roles:

  • not-applicable: a parent step ended denied, hidden or read-only for the role, so the step has nothing to do. The agent skips it, and the whole branch below it gets the same state for that role. It is neither a failure nor a block.
  • no-account: on ui, the role has no accounts entry, or its env vars are missing from mcp.secretsFile. Every step of its pass gets this state, and the other roles still run.

/runbooks:to-test writes one test per step and role, named S01 <title> as editor, with in <case> after the role on a runbook with cases and @ <viewport> after it on a mocked ui runbook with viewports. A live ui script has one test per role and viewport instead. A mocked runbook keeps the responses of each role in @<role> fixture folders and of each case in +<case> folders, and the run selects the role and the case on the upstream server before each pass.

After a role changes

A runbook's text can go wrong when a role changes, so the role commands mark the runbooks they reach with a review entry. A run does not clear it, because running the old text proves nothing new. Only /runbooks:update clears it, after you confirm each role's Expected. A runbook in review still runs, with a warning, and still counts as coverage.

permissions:manage

marks for review

/runbooks:list --review

finds them

/runbooks:update

clears the review

A permission change marks the runbooks it reaches. list --review finds them, update writes the role lines and clears the review, and a run proves the new text.
ReasonSet when
roles-enabledTracking was turned on, so every Expected needs its role lines.
role-permissionsA permission of the role on the runbook's key changed, such as delete removed.
role-createdA new role has no Expected lines yet.
role-deletedThe heirs of a deleted role lost permissions.
context-createdA context was added, or a key was added to one, so the runbook now runs per case.
case-createdA case was added to the runbook's context and has no Preconditions line or role lines yet.
❯ /runbooks:list --review # the runbooks waiting for role lines
❯ /runbooks:update billing.issue-invoice # write them and clear the review

Coverage by role

/runbooks:coverage adds a section that counts each role and matrix key as one unit. When the role has access to the key, any active or draft runbook of that key that runs the role covers it. When the role has no access, only a runbook with a denied, hidden or read-only line for the role covers it, because a denial that no step proves is not covered. On a key of a context, the unit is a role, a key and a case. --role limits the section to one role.

❯ /runbooks:coverage --role editor

On the runbooks page, the Roles section at #/roles lists each role with its parents, account, runbooks, pending reviews and last results, and a permission matrix with one letter per action. Muted cells are inherited and yellow cells are overridden. A role's page at #/roles/<name> shows its effective permissions, where the role was found, the keys still marked guess, its runbooks and the permission history. A runbook's page shows the last run per role and viewport, and the coverage of one role is at #/coverage?role=<name>.