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
| Question | Without | With the matrix |
|---|---|---|
| Where the rules live | Guards, policies, middleware and UI conditionals | roles.json, one key per capability and feature |
| An action beyond view, create, edit and delete | A string in the code | A project action in actions, such as approve |
| A role with everything another role has, minus delete | A copy of the rules, kept in sync by hand | inherits, plus an own entry that replaces the key |
| A rule nobody confirmed | Looks like a confirmed one | Marked guess until someone edits it |
| A spreadsheet from the product team | Read once, then forgotten | roles:generate merges it into roles.json |
| After a change | No list of what it affects | The 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
manager
invoices: + approve
/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 themThe command maps each verb of the document to an action:
| The document says | Action |
|---|---|
| see, list, read | view |
| add, register | create |
| change, update | edit |
| remove | delete |
| approve, export, cancel, any other verb | a 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
roles.json
merged
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| Command | What it does |
|---|---|
permissions:manage | Changes 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:generate | Reads 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:list | With 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