Skip to content

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

QuestionWithoutWith contexts
A manager who edits only the clients of their teamA fake role per situation, or a test that opens one clientOne manager role with its actions per case
Test accountsOne per fake role, all of them the same loginOne per role. Each case builds its own record
A limit a step checkspartial, which names no recordA role line per case, checked like any Expected
A role whose access does not change by caseRepeated on every recordOne pass, under the case *
CoverageThe route counts onceCounted per role, key and case
Renaming a caseSearch and replace across testscontexts: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 contexts is 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/profile belongs to client-access because the context lists clients/*.
  • 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

A run goes through each role, inside each role through each case, and inside each case through each viewport. A case whose record cannot be built makes its pass blocked, and the next case still runs.

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 cases

A 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 regular

Coverage 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.

CommandWhat it does
contexts:listLists the contexts with their keys, cases and runbook count. With a name, it lists the runbooks on the context's keys.
contexts:createRegisters a context with its description, keys and at least two cases, and offers to mark the runbooks on those keys with context-created.
contexts:updateAdds 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:deleteRemoves 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-client

Limits

  • 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.