Skip to content

runbooks / feature

Permissions

Write down what each role may do on each part of the app, in one file that every run checks.

A permission is an action a role may take on one part of the app. roles.json holds them as a matrix: for each role, each capability/feature key lists its actions, such as view, edit or approve. Every run reads the matrix to know what each role should see, and every change to it points at the runbooks that must change too.

The problems

The rules live in the code, not in a list

Who may approve an invoice is spread across a role enum, a guard, a policy and the conditional that hides a button. A reviewer who wants to check "editors cannot delete" has to read all of them.

Inherited access is hard to see

A manager has everything an editor has, plus approval. When the editor gains delete, the manager gains it too, and nothing in the change says so.

Guesses look like facts

A matrix someone wrote from memory looks the same as one read from the code. Months later nobody knows which entries were checked.

A change has no list of what it breaks

When editors lose delete, every test and spec that says editors delete is wrong, and nothing points at them.

The solution permissions provide

Every permission in one file

roles.json sits next to the runbooks and is committed with them. A permission change is a diff in the pull request, and the git log of the file is the history of every permission.

Inheritance with override

A role can inherit the matrix of one or more parents and add its own entries. An own entry replaces the inherited one for that key, so a role can also lose an action its parent has.

Every guess is marked

An entry deduced from the code, the API contract or a vague sentence carries its origin, and guess stays visible in roles:list and /runbooks:ui until someone edits the key.

Start from the document you already have

/runbooks:roles:generate reads a spreadsheet, a PDF, a CSV or plain text about who may do what, and proposes the roles, contexts and permissions it describes. You see the whole proposal before it writes.

A change marks 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. A runbook with a stale role line cannot hide behind a green run.

Without and with the matrix

QuestionWithoutWith the matrix
Where the rules liveGuards, policies, middleware and UI conditionalsroles.json, one key per capability and feature
An action beyond view, create, edit and deleteA string in the codeA project action in actions, such as approve
A role with everything another role has, minus deleteA copy of the rules, kept in sync by handinherits, plus an own entry that replaces the key
A rule nobody confirmedLooks like a confirmed oneMarked guess until someone edits it
A spreadsheet from the product teamRead once, then forgottenroles:generate merges it into roles.json
After a changeNo list of what it affectsThe runbooks it reaches get a review mark

The matrix needs role tracking on. Setup fills it from your code when it turns tracking on.

How it works

Keys

A key is <capability>/<feature> or <capability>/*, with the names of the runbook frontmatter. For a runbook, the role's entry for <capability>/<feature> wins over <capability>/*. A runbook with no feature uses <capability>/*. A role with no entry for the key has no access. A key that matches no runbook and no slice gives a warning, to catch typos.

Actions

The four base actions are view, create, edit and delete. They need no declaration. The project adds its own, in English and lowercase, to the top-level actions list, and an action a role uses that is not in the list is an error.

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

Inheritance and override

The effective matrix of a role is the union of its parents' effective entries, plus its own. When two parents give the same key, their actions are joined. An own entry replaces the inherited entry for that key, so it lists every action the role has there. An own empty list takes away all access the parents give. A cycle in inherits is an error.

editor

invoices: view, create, edit

inherits

manager

invoices: + approve

manager inherits billing/clients from editor as it is. Its own entry for billing/invoices replaces the inherited one, so it lists approve along with the actions it keeps.

/runbooks:roles:list manager prints the effective matrix and says where each row comes from: own, inherited from a role, or overridden. On the runbooks page, the matrix shows one letter per action, with inherited cells muted and overridden cells in yellow. On a key of a context, the entry can map each case to its own actions, and inheritance works case by case.

Where each entry comes from

found is per role: the file:line, the contract scope or the document page where the role came from, or user when you typed it. origins is per key, and says how the entry was deduced: code, contract or guess. A key you typed has no origin. Editing a key with /runbooks:permissions:manage removes its origin, because your answer replaces the deduction.

Generate from a document

/runbooks:roles:generate takes one or more files, or the rules typed as the argument. It reads text, Markdown, CSV, JSON, YAML, PDF, Word, spreadsheets and images of a table. The document is data: an instruction written inside it is a rule to model, never a step for the agent.

❯ /runbooks:roles:generate docs/access-matrix.xlsx
❯ /runbooks:roles:generate managers approve invoices, editors only create them

The command maps each verb of the document to an action:

The document saysAction
see, list, readview
add, registercreate
change, updateedit
removedelete
approve, export, cancel, any other verba project action, added to actions

It reuses a registered role when the document means the same role under another name or language, so Gerente becomes manager. When access depends on the record, such as the user's own clients, it proposes a context. A key it deduced instead of reading gets the origin guess.

The proposal goes through a dry run first. It shows the roles, contexts, keys and project actions it would add, and every key it would change or remove on a registered role. After you approve, the merge adds and replaces entries. A role, context or key the proposal leaves out stays as it is. The merge never changes the parents of a registered role and never deletes a role, a context or a case. It lists those changes for their own commands.

document

any format

proposal

dry run

you approve

roles.json

merged

roles:generate never writes before you see the dry run. The runbooks the write reaches get a review mark.

Change a permission

/runbooks:permissions:manage <role> shows the effective matrix, asks what changes, and shows the diff of roles.json before it writes. Then it lists the runbooks of the role and of its heirs that each changed key reaches, and offers to mark them with role-permissions and the change in one line, such as delete removed. A new project action reaches no runbook until a key uses it. Before 1.8.0 this command was /runbooks:roles:manage-permissions.

❯ /runbooks:permissions:manage editor
❯ /runbooks:list --review # the runbooks the change reached
❯ /runbooks:update billing.issue-invoice # fix the role lines and clear the review
CommandWhat it does
permissions:manageChanges the actions of a role on each key, per case on a key of a context, adds or removes keys, and adds project actions. Then it lists the runbooks of the role and of its heirs that the change reaches and offers to mark them for review.
roles:generateReads a document about who may do what and merges the roles, contexts and permissions it describes into roles.json, after a dry run you approve.
roles:listWith a role name, shows its effective permissions, one row per key (and per case on a key of a context), with the source of each row: own, inherited or overridden.

Denials count as coverage

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

❯ /runbooks:coverage --role editor