runbooks / feature
Contexts
Give the same role different access per record, and run each case as its own pass, with the same login.
A role says who the user is. Some rules also depend on the record the user opens. A manager edits the VIP clients of their own team, only views the VIP clients of other teams, and edits every client that is not VIP. A context holds that question about the record, and each answer to it is a case. The manager keeps one role and one login, and each runbook on those screens proves what the manager gets in every case.
The problems
The role does not decide alone
The code checks the user's role and a field of the record: the client's VIP flag, the team that owns it, the invoice status. One list of actions per role and screen cannot hold that rule, so the permissions file and the code disagree on every record where it matters.
A fake role per situation
One way out is a role per case, such as manager-vip-team and manager-vip-outside. Each one needs its own test account that is really the same login, and the list of roles stops matching the roles the code defines.
partial says there is a limit, not where
A step line such as manager: partial admits that the manager is limited. It does not say on which records, so no step can test the limit and coverage cannot say which records the role reaches.
One test opens one record
A manager test that opens a client of the manager's team passes. Nobody opens a VIP client of another team as the manager, so a missing check on the team ships.
The solution contexts provide
The question about the record has a name
A context lives in roles.json next to the roles. It has a one-line question, the matrix keys it reaches and its cases, each with one sentence that says which record it describes. The rule that was spread across a guard and a template becomes a diff anyone can review.
Actions per case, on the same key
On a key of a context, a role can map each case to its own actions. A role whose actions do not depend on the record keeps a plain list, and the list applies in every case.
One login, one pass per case
A runbook on a key of a context runs once per case inside each role. The role logs in once, and each case builds its own record from the runbook's Preconditions. A line such as manager in vip-outside: read-only states the limit, and the run checks it like any other Expected.
Roles whose access does not change run once
A viewer who only views clients gets the same result on every record. That role runs one pass, under the case *, instead of repeating the steps for every case.
Without and with contexts
| Question | Without | With contexts |
|---|---|---|
| A manager who edits only the clients of their team | A fake role per situation, or a test that opens one client | One manager role with its actions per case |
| Test accounts | One per fake role, all of them the same login | One per role. Each case builds its own record |
| A limit a step checks | partial, which names no record | A role line per case, checked like any Expected |
| A role whose access does not change by case | Repeated on every record | One pass, under the case * |
| Coverage | The route counts once | Counted per role, key and case |
| Renaming a case | Search and replace across tests | contexts:update rewrites runbooks and +<case> folders |
Contexts need role tracking on. A registry with no contexts behaves as before, and a runbook on a key of no context has no cases.
How it works
Define a context
/runbooks:contexts:create asks the question, the keys and the cases, shows the diff of roles.json and writes it after you approve. A key is <capability>/<feature> or <capability>/*, as in the permission matrix.
❯ /runbooks:contexts:create client-access{
"version": 2,
"actions": [],
"contexts": {
"client-access": {
"description": "How the user relates to the client the record belongs to",
"keys": ["clients/*", "billing/invoices"],
"cases": {
"vip-team": "VIP client, the user is on its team",
"vip-outside": "VIP client, the user is not on its team",
"regular": "client that is not VIP"
}
}
},
"roles": {
"viewer": { "permissions": { "clients/*": ["view"] } },
"manager": {
"permissions": {
"clients/*": {
"vip-team": ["view", "create", "edit", "delete"],
"vip-outside": ["view"],
"regular": ["view", "create", "edit"]
}
}
}
}
}- A file with
contextsis at"version": 2. The first context moves the file there, and deleting the last one moves it back to 1. - A key belongs to one context.
clients/profilebelongs toclient-accessbecause the context listsclients/*. - Case names are kebab-case and unique across every context, so a case alone names it in role lines, fixture folders and test names.
- A context needs at least two cases. With fewer, the loader warns.
Actions per case
On a key of a context, a role's entry is a list when its actions are the same in every case, and a map from case to actions when they differ. A case the map leaves out is no access in that case. /runbooks:permissions:manage asks first whether the actions are the same in every case, then asks one list or one list per case.
Inheritance works case by case. A parent's list counts as the same list in every case, and two parents join their actions per case. An own entry still replaces the inherited entry for the whole key, so an own map that leaves out a case takes that case away. /runbooks:roles:list manager shows the effective matrix with one row per key and case.
A runbook with cases
A runbook whose capability/feature matches a key of a context lists the cases it runs in cases, or all. Its Preconditions say how to build the record of each case, under a Cases: list. A role line can name cases with in <case>.
---
id: clients.edit-client
capability: clients
feature: profile
roles: all
cases: all
---
## Preconditions
- Account: each role's login from `accounts`.
- Cases:
- vip-team: client "ACME" with `vip: true`, the role's account added to its team.
- vip-outside: client "Globex" with `vip: true`, the role's account not on its team.
- regular: client "Initech" with `vip: false`.
### S02. The manager edits the client
Parents: S01
Page: /clients/{id}
Expected: the page shows "Saved" and the phone "555-0100".
- manager in vip-outside: read-only. The "Edit" button does not appear.
- viewer: read-only. The "Edit" button does not appear.manager has no line for vip-team and regular, so it is allowed there. A line with a case wins over a line without one for that role and case. A Cases: line on a step limits it to some cases, as Roles: does for roles. A case adds no new outcome, so the five outcomes of roles still apply.
Roles that run once
A role runs one pass, not one per case, when all three hold:
- its effective actions on the runbook's key are the same in every case of
cases; - no role line names that role with a case;
- no step of the runbook has a
Cases:line.
Its pass builds the record of the first case of cases, and its states carry the case *. anonymous usually runs once. In the example, viewer runs once and manager runs three passes.
Runs, scripts and fixtures
role
logs in once
case
builds its record
viewport
runs the steps
Between two cases, the run follows the runbook's Cleanup, then builds and checks the next record. A report keeps one state per step, role, case and viewport. --case runs only some cases and makes the run partial, like --role, so status and last_run stay as they are.
❯ /runbooks:run clients.edit-client # every role, every case
❯ /runbooks:run clients.edit-client --case vip-team,regular # only some casesA test name carries the case after the role, as in S02 The manager edits the client as manager in vip-outside. A role that runs once has no in <case> part. A mocked runbook keeps the responses of each case in +<case> folders, for the whole runbook, one step, one role or one step and role. The most specific folder answers first, and a case with no folder gets the role's and the runbook's responses.
_fixtures/clients/edit-client/
├── client.json
├── +vip-team/
│ └── client.json # every role, in vip-team
├── @manager/
│ └── +vip-outside/
│ └── client.json # manager, in vip-outside
└── S02/
└── +regular/
└── client.json # step S02, in regularCoverage per case
On a key of a context, the coverage unit is a role, a key and a case. A runbook covers a case only when its cases include it. When the role has no access in that case, only a denied, hidden or read-only line that applies there covers it. The by-role table of /runbooks:coverage gets a Case column, and --role shows the cases under each key.
Change a context
Four commands manage contexts. contexts:list only reads. The others show the diff before they write, and commit by your policy.
| Command | What it does |
|---|---|
contexts:list | Lists the contexts with their keys, cases and runbook count. With a name, it lists the runbooks on the context's keys. |
contexts:create | Registers a context with its description, keys and at least two cases, and offers to mark the runbooks on those keys with context-created. |
contexts:update | Adds or removes keys, adds, renames or deletes cases, or changes the description. A rename or a delete rewrites the runbooks and the fixture folders in the same write. |
contexts:delete | Removes the context from the registry and from its runbooks. Each role with actions per case keeps the actions of the case you choose. |
A new context, or a new key in one, marks the runbooks it reaches with context-created: they now run per case and need cases, a Cases: list and case role lines. A new case marks them with case-created. A renamed or deleted case and a deleted context mark nothing, because the command rewrites the runbooks itself. /runbooks:update writes the missing lines and clears the review.
❯ /runbooks:contexts:update client-access # add a case
❯ /runbooks:permissions:manage manager # give manager its actions in the new case
❯ /runbooks:list --review
❯ /runbooks:update clients.edit-clientLimits
- One context per key. Two questions on the same key, such as VIP status and invoice status, need one context with the combined cases.
- The plugin never evaluates the condition in your code. The Preconditions say how to build each case's record, and the run checks what each role sees on it.