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
| Question | Without | With role tracking |
|---|---|---|
| The roles a run checks | The test account, often an admin | Every role in the runbook's roles |
| Where permissions are written | Across guards, policies and UI conditionals | roles.json, reviewed in the pull request |
| A role that must be denied | Untested unless someone writes that test | A denied, hidden or read-only line the run checks |
| Coverage of a route | Covered once any test touches it | Counted per role and key, denials included |
| After a permission change | Stale tests fail later, or never | The runbooks it reaches get a review mark |
| A role with no test account | A failed login | no-account, and the other roles still run |
| Access that depends on the record | Checked on whichever record the test opens | One 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 rolesTurning 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
manager
own entry: + approve
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.
| Command | What it does |
|---|---|
roles:list | Lists 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:create | Registers a role with its description, parents, account and permissions, and offers to mark every roles: all runbook with role-created. |
roles:update | Changes the description, parents or account, or renames the role in every runbook line and fixture folder that names it. |
roles:delete | Removes the role from the registry, the config and the runbooks. Heirs either keep a copy of what they inherited or lose it. |
roles:generate | Reads 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 permissionsWhat 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.| Outcome | Meaning |
|---|---|
allowed | The common Expected applies. A role with no line is allowed. |
read-only | The role sees the screen and cannot act. |
hidden | The control or the screen does not appear. |
denied | A 403 page, a redirect or an error. |
partial | The 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 runTwo states exist only with roles:
not-applicable: a parent step endeddenied,hiddenorread-onlyfor 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: onui, the role has noaccountsentry, or its env vars are missing frommcp.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
| Reason | Set when |
|---|---|
roles-enabled | Tracking was turned on, so every Expected needs its role lines. |
role-permissions | A permission of the role on the runbook's key changed, such as delete removed. |
role-created | A new role has no Expected lines yet. |
role-deleted | The heirs of a deleted role lost permissions. |
context-created | A context was added, or a key was added to one, so the runbook now runs per case. |
case-created | A 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 reviewCoverage 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 editorOn 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>.