runbooks / reference
Runbook format
Location and id
<paths.runbooks>/<capability>/<flow>.md, exactly one folder deep, kebab-case names. The id is <capability>.<flow>. Folders whose names start with _ hold reports and are never runbooks.
Frontmatter
id: billing.issue-invoice
title: Issue a client invoice
capability: billing
surface: ui
feature: invoices
tags: [financial, critical]
roles: [admin]
viewports: [desktop, mobile]
design:
invoices-list: "https://www.figma.com/design/AbC123/Billing?node-id=12-34"
invoices-list.mobile: "https://www.figma.com/design/AbC123/Billing?node-id=12-90"
issue-dialog: "https://www.figma.com/design/AbC123/Billing?node-id=15-2"
covers:
entries: ["/billing/invoices", "POST /admin/internal/invoices"]
requirements: ["CHE-581#AC2"]
calls: [GET /admin/internal/clients/{id}]
sources: ["docs/superpowers/specs/2026-09-18-CHE-581-design.md", "CHE-581"]
scripts: []
status: draft
last_run:
date: 2026-10-16
oldest: 2026-10-16
commit: "abc1234"
result: passed
report: _runs/2026-10-16-1432-billing.issue-invoice.md
runs:
r1: { date: 2026-10-16, commit: "abc1234", report: _runs/2026-10-16-1432-billing.issue-invoice.md }
cells:
S01: passed r1
S02: passed r1| Key | Meaning |
|---|---|
id |
must equal <capability>.<flow> from the path |
title |
the user outcome, in the config language |
capability |
must equal the folder name; also the slice name used for coverage |
surface |
ui (verified in the browser) or api (verified by its script). Missing means ui. Must be a surface the config stack allows |
feature |
finer grouping inside a capability; free text |
tags |
free labels; critical marks the runbooks to run first |
roles |
with role tracking off (config roles.enabled false): account roles from the config accounts that the steps log in as, and all is a problem. With tracking on: all, or a list of registered role names that run the runbook. all includes anonymous. A missing roles means all, and create writes all explicitly. A runbook that skips a role lists the roles it runs ("Roles" below) |
cases |
tracking on, and only on a runbook whose capability/feature matches a key of a context (config.md, "Contexts"): all, or a list of cases of that context, the ones the runbook runs. A missing cases means all, and create writes all explicitly ("Contexts and cases" below) |
review |
tracking on only: a list of marks that the runbook's text may be wrong after a role change. Each entry holds a reason, a date and the fields of its reason ("Review" below). Missing means nothing to review |
viewports |
ui only: names from the config viewports, the screen sizes the steps run at, in run order. Missing or empty means the steps run once, at the browser's default size |
design |
ui only, and only when the config has design: a map from a screen name (kebab-case) to its design reference, a frame link or a file path (config.md, Design). <name>.<viewport> is the variant used at that viewport; without one, every viewport uses <name>. Steps cite the names in their Design: line |
upstream |
ui only: live or mocked, how the runbook reaches its upstream API. Written only when it differs from the config upstream.mode (upstream.md, "Modes"). A mocked runbook needs upstream.urlEnv in the config |
covers.entries |
entry ids exactly as runbooks.mjs entries prints them |
covers.requirements |
requirement ids exactly as runbooks.mjs requirements prints them, or ids from the source when no requirement source is configured |
calls |
ui only, and only when the config has upstream: the operations of an upstream API outside this repository that the screens reach, as METHOD /path with {param} segments, spelled as the API's OpenAPI document spells them when upstream.schema is set. Next.js segments ([id], [...path]) are read as {id} and {path}. An endpoint that runbooks.mjs entries lists goes in covers.entries instead. calls never counts as coverage and never makes an orphan (${RUNBOOKS_PLUGIN_ROOT}/docs/adr/0005-upstream-api-in-another-repo-is-a-contract.md) |
sources |
spec paths, ticket ids, diff <base>...<sha>, file <path>, dir <path> and route <entry id> for code read as the source, map <sha> for a placeholder, and test files read as evidence only (tests of the code behind an entry, which cannot run the steps), that the runbook came from |
scripts |
the automated tests that run this runbook's steps, written or adopted by to-test. Optional for ui; required for api, whose runbook runs only through it. In a placeholder, the existing tests map fitted to the flow, which may cover it only in part: candidates to-test offers to adopt |
status |
placeholder, draft, active or deprecated (see Status) |
last_run |
the state of every cell and the run that proved it, in the shape of "last_run" below. date, commit and report (path relative to paths.runbooks) copy the newest run; result is passed, failed, blocked or incomplete. outdated: true when update or audit --fix changed the runbook after a run. A run clears outdated on the cells it ran, and the key goes away when no cell has it |
The parser reads a YAML subset: key: value maps nested by two-space indentation, lists as [a, b] or - item lines of scalars, inline maps { k: v }, quoted strings, # comments after a space. Brackets inside an id stay part of it ([/deliveries/[id], /x] is two ids), and a flow list may continue over several lines until its ]. Quote any value that starts with [, {, " or ', or that contains , inside a flow list. Quote commit hashes so digits-only hashes stay strings.
Body
Section headings stay in English so commands find them; text under them follows the config language.
# <title>
---
## Goal
---
## Preconditions
---
## Steps
### S01. <what this step proves>
---
### S02. <what this step proves>
---
## Not covered
---
## CleanupA --- line goes before every ## heading and before every step heading except the first, which follows ## Steps directly. Keep a blank line above and below each ---: right under a line of text, Markdown reads it as a heading underline.
Each step:
Parents:the steps whose failure invalidates this one, ornone.Requirement:the requirement id it verifies, ornone.uisteps:Page:the route the step starts on, as an entry id.Design:optional, the name indesignof the screen the step ends on.runcompares that screen with its design.Viewports:optional, the viewports the step runs at when it runs at only some of the runbook's: an action or an Expected that exists only at some sizes, such as a menu behind a button on mobile. Missing means every viewport of the runbook.Actions:numbered, naming each control by its visible label.Expected:the exact UI text and state. A persistence check reloads the page and states what remains.
apisteps:Endpoint:the endpoint called, as an entry id (POST /admin/internal/invoices).As:the role making the call, oranonymous. With role tracking on, role lines replace it (see "Roles" below and "As: during the migration").Request:the request as anhttpcode block: method, path and query, the headers that matter, and the JSON body, with data named after the Preconditions (see "Request and response of api steps").Expected:the status, the body fields as ajsoncode block, then the effects below (see "Request and response of api steps").
Number steps S01, S02, and keep numbers stable: a removed step leaves a gap.
Roles
With role tracking on, a runbook runs the same steps once per role, like viewports, and each step states what each role should see. The registry, its keys and its inheritance: config.md, "Roles".
roleslists the roles that run the runbook, orallfor every registered role,anonymousincluded. An empty list (roles: []) or a role outside the registry is a problem.Roles:is an optional line on a step, likeViewports:. It limits the step to some of the runbook's roles (Roles: editor, manager). Missing means every role of the runbook. A role inRoles:that the runbook does not run is a problem.Expected:holds the result common to all roles. After it come one line per role whose behavior differs, each starting with the role name and an outcome keyword:Expected: the list shows invoice "INV-001". - editor: read-only. The "Delete" button does not appear. - anonymous: denied. Redirects to /signin.A role line for a role that is not one of the step's roles is a problem. A bullet whose keyword is not in the table below (
- status: paid) is plain Expected text, not a role line. Role lines end at the next field line,---line or heading, so bullets under Not covered and Cleanup are never role lines.
| 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 screen does not appear |
denied |
a 403 page, a redirect or an error |
partial |
the role acts with limits. The text after the keyword says which |
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.
With role tracking off, role lines are plain text and no keyword is checked.
Contexts and cases
With role tracking on, a runbook whose capability/feature matches a key of a context (config.md, "Contexts") runs once per case, as it runs once per role and per viewport. Each step then states what each role gets in each case. That context is the runbook's context. A runbook that matches no key of a context has no cases, and nothing in this section applies to it. Why a case runs inside a role instead of being a role of its own: ${RUNBOOKS_PLUGIN_ROOT}/docs/adr/0006-a-case-is-a-run-dimension-inside-a-role.md.
caseslists the cases the runbook runs, orallfor every case of its context.Preconditions say how to build the record of each case, under a
Cases:list with one line per case ofcases:## 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`.A
liverunbook builds this data with the run suffix. Amockedrunbook gets it from its+<case>fixture folders (upstream.md, "Case layer"). Anapiscript builds it in each test, with the project's factories.Cases:is an optional line on a step, likeRoles:andViewports:. It limits the step to some of the runbook's cases (Cases: vip-team, regular). Missing means every case of the runbook. A runbook with aCases:line on any step runs every role once per case ("Roles that run once" below).A role line can name cases:
- <role> in <case>[, <case>...]: <outcome>. <text>. A line with no case applies to every case the step runs. For one role and one case, the line with the case wins over the line without one. A case adds no outcome keyword.### S02. The manager edits the client Parents: S01 Requirement: none Page: /clients/{id} Actions: 1. Click "Edit". 2. Change the phone to "555-0100" and click "Save". 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.managerhas no line forvip-teamandregular, so it isallowedthere.viewerhas the same actions in every case, so one line covers all of them.
Roles that run once. A role runs once, not once per case, when 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, and no step of the runbook has a Cases: line. Its pass builds the data of the first case of cases, and its states carry the case *. Under run --case, it builds the first case of cases that the run selected. anonymous usually runs once. Every other role runs once per case of cases. runbooks.mjs passes <id> lists the passes: role, case, the case whose data the pass builds, viewport and account.
Problems the loader reports:
cases needs role tracking (roles.enabled)cases needs a context: <capability>/<feature> belongs to no contextcases must list at least one case, or be allcase "<name>" is not a case of <context>case "<name>" appears twice in casesPreconditions: case "<name>" has no line under Cases:Preconditions: Cases: "<name>" is not one of the runbook's cases<step>: Cases: "<name>" is not one of the runbook's cases<step>: role line "<role> in <case>" names a case the step does not run<step>: role line "<role> in <case>": the runbook has no context<step>: two role lines for <role> in <case>
Review
review says the runbook's text may be wrong and needs editing. It is separate from last_run.outdated, which says the runbook needs another run. Each entry has a reason, a date and the fields of that reason:
| Reason | Fields | Set when |
|---|---|---|
roles-enabled |
none | tracking was turned on, so every Expected needs its role lines |
role-permissions |
role, key, change, and case when the change is in one case |
a permission of the role on the key changed. change is one line (delete removed), and names the case when there is one (vip-outside: edit removed) |
role-created |
role |
a new role has no Expected lines yet |
role-deleted |
role |
a deleted role's heirs lost permissions |
context-created |
context |
a context was added, or a key was added to one. The runbooks it now reaches run per case and need cases, the Preconditions Cases: list and case role lines |
case-created |
case |
a case was added to a context. Its runbooks need its Preconditions line and its role lines |
roles: all
review:
- { reason: roles-enabled, date: 2026-10-21 }
- { reason: role-permissions, role: editor, key: billing/invoices, change: "delete removed", date: 2026-10-22 }
- { reason: case-created, case: vip-new, date: 2026-10-23 }A renamed or deleted case, and a deleted context, mark nothing: runbooks.mjs rename-case, delete-case and delete-context rewrite the runbooks themselves. Deleting a case removes it from every cases list, Cases: line and role line, and deletes a role line left with no case.
Only update of that runbook clears review, after the user confirms each role's Expected. A run does not clear it, because running old text proves nothing new. run still runs a runbook in review and warns before the first step. A runbook in review still counts as coverage. A runbook with tracking off and a review key has a problem.
As: during the migration
As: is replaced by role lines. While a runbook's review holds roles-enabled, As: stays valid, and update turns each As: step into role lines. A runbook with no such review that still has As: gets a warning.
What a step can verify
ui: every expected result is observable in the browser. A behavior the UI does not show goes under "Not covered" with the reason. In amockedrunbook the step verifies what the screen shows and the request it sent (method, URL, body), never persistence. A step that would reload the page to check what the API saved checks that request instead, and persistence goes under "Not covered". The design comparison of aDesign:line is a separate finding: it never changes the step's state.api, in this order of preference:- the response: status, body fields, headers;
- persistence, through another call (
GET /invoices/{id}after the POST), the API's own reload check; - effects no endpoint shows (an e-mail, a queued job, a call to an external service), through the framework's fakes (
Mail::fake(),Queue::fake(), a mocked HTTP client); as a last resort, the framework's own database assertion (assertDatabaseHas) inside the test database; - what none of these reach (real delivery by the e-mail provider) goes under "Not covered" with the reason.
Items 3 and 4 live only in the script, in the project's isolated test environment. The agent itself still reads no database and writes no SQL.
An api runbook covers at least: the main path, invalid input (422 with the fields), a role that must not get in (403), no authentication (401), and a read-back for anything it saves. With role tracking on, the 403 and 401 cases become role lines on the main step instead of steps of their own: denied for a signed-in role and denied for anonymous, each with its status and body. The 422 step and the read-back stay steps. Its Preconditions say how the script creates each piece of data (factory, seed, a call to another endpoint), and Cleanup names the isolation that undoes it (RefreshDatabase, a transaction per test).
Request and response of api steps
Request: stands alone on its line, followed by an http code block: the request line with the method, the path and the query string, then the headers that matter, a blank line and the JSON body. A value known only at run time goes in angle brackets and names its source in the Preconditions (/invoices/<id of the draft invoice>). Credentials stay out of the block: As: or the role lines name who calls, and the helper from the agents block signs the call. A body that is not JSON, such as a file upload, is a sentence after the block.
Expected: gives the status on its line (Expected: 200.), followed by a json code block with the body fields the step checks:
- A key left out is not checked.
- An array lists its items in the order the response returns them, and the script checks that order and the length. When only some items matter, or the order does not, a sentence after the block says so.
- A value in angle brackets is checked by its type or its source, not as text:
"<uuid>","<ISO 8601>","<integer>","<id of the draft invoice>". It is always a JSON string, so the block stays valid JSON. - A response with no body (204) has no block.
The effects (a read-back call, a fake) and the role lines come after the block, as text. A role line that states a body puts its block under the line, indented. Lines inside a code block are never fields or role lines.
Request:
```http
GET /quotes/airports?page=1&perPage=50&sortBy=name&sortOrder=asc
```
Expected: 200.
```json
{
"meta": { "total": 3, "page": 1, "perPage": 50, "totalPages": 1 },
"data": [
{ "id": "<uuid>", "iataCode": "MIA", "name": "Miami International", "createdAt": "<ISO 8601>" },
{ "id": "<uuid>", "iataCode": "GIG", "name": "Rio de Janeiro/Galeão", "createdAt": "<ISO 8601>" },
{ "id": "<uuid>", "iataCode": "GRU", "name": "São Paulo/Guarulhos", "createdAt": "<ISO 8601>" }
]
}
```
- viewer: denied. 403 with
```json
{ "message": "Forbidden" }
```Preconditions and the upstream mode
The Data line of a mocked runbook points to its fixtures (<upstream.fixtures>/<capability>/<flow>/), which are its data. Over a GraphQL API, each operation the page sends has its own fixture, named by request.operationName, so a precondition such as "the client belongs to no group" is the fixture of the operation that reads the groups (upstream.md, "GraphQL operations"). A live runbook states how each piece of data is created, and the data it creates carries the run suffix (RUNBOOKS_RUN_ID), so Expected quotes only the fixed part and Cleanup deletes what the run made. The rest: ${RUNBOOKS_PLUGIN_ROOT}/skills/runbooks/references/upstream.md.
Flow
One runbook proves one flow. On ui: what a user does across screens to get one outcome. On api: one business operation, meaning a write plus the reads that prove its effect (create-invoice: POST /invoices and GET /invoices/{id}), or a read with logic of its own such as filters, pagination or scoping by role (list-invoices). One runbook per endpoint, or one per resource, is the wrong size.
Scripts of api runbooks
One test per step, its name starting with the step id (S01 admin creates a draft invoice; test_s01_... or testS01... where names cannot have spaces). runbooks.mjs junit maps the JUnit report back to steps by that prefix.
With role tracking on, an api script has one test per step and role, named S01 <title> as <role>. A mocked ui script has one test per step, role and viewport, named S01 <title> as <role> @ <viewport> (the @ <viewport> part only with viewports). junit reads the step, the role and the viewport from the name. A live ui script has one test per role and viewport, named <title> as <role> @ <viewport>, holding one test.step('S01 <title>') per step, and mode test is not offered for it (instructions/to-test.md, item 5). With cases, the case follows the role: S01 <title> as <role> in <case> for api, S01 <title> as <role> in <case> @ <viewport> for mocked ui, and <title> as <role> in <case> @ <viewport> for live ui. A role that runs once has no in <case> part. junit reads the case from the name too, and gives the reason when a test fits no pass: no " in <case>" suffix, <case> is not one of the runbook's cases, <role> runs once; drop " in <case>", or <step> does not run in <case>. A role's test for a step whose parent ended denied, hidden or read-only for that role is not written, and junit reports that step as not-applicable. A ui role with no entry in the config accounts gets no tests, and junit reports its steps as no-account. junit checks only accounts. A live run also gives no-account to a role whose env var names are missing from mcp.secretsFile (run-modes.md, "Roles").
Tests in the file without a prefix stay outside the runbook's result. The runbook is the source of truth: when a test's assertion and its step's Expected differ, the script changes, unless the user says the runbook is wrong. Why: ${RUNBOOKS_PLUGIN_ROOT}/docs/adr/0001-api-runbooks-run-through-their-script.md.
Dependency tree
A step depends on another when the parent's failure invalidates the child's result: the child uses state the parent creates, or checks something that exists only if the parent worked. Shared setup and convenient order are not dependencies.
- A step runs when all its parents are
passed. Onapi, every test runs, andrunbooks.mjs junitapplies the parent rules to the results afterwards. - A parent that is
failed,blockedorblocked-by-parentmakes the childblocked-by-parent, down the whole branch. - Run order: every parent before its children. Among independent steps, keep the open screen, and leave steps that issue, cancel or delete data for last.
- The tree has no cycles.
- With
viewports, the tree runs once per viewport, in the frontmatter order. Each step runs only at the viewports itsViewports:line allows, and its parents must run at each of them. A parent's state counts only for its children at the same viewport. - With role tracking on, the tree runs once per role of the runbook, and inside each role once per viewport. Each step runs only for the roles its
Roles:line allows. A parent's state counts only for its children of the same role and viewport. - With cases, the tree runs once per role, inside each role once per case of its passes (once, under
*, for a role that runs once), and inside each case once per viewport. Each step runs only in the cases itsCases:line allows. A parent's state counts only for its children of the same role, case and viewport. - A parent that passes with the outcome
denied,hiddenorread-onlyfor a role closes the branch for that role (with cases, for that role in that case, by the role line that applies there): its children arenot-applicablefor that role, because their action is not available either. Anot-applicableparent makes its childrennot-applicablefor that role too, all the way down the branch. A parent that isfailed,blockedorblocked-by-parentstill makes the childblocked-by-parent, and that rule wins when both apply. The children still run for the roles whose parent wasallowedorpartial.
Step states
Only run reports hold step states. With viewports, a step has one state per viewport it runs at. With role tracking on, it has one state per role too. With cases, it has one state per role, case and viewport, and a role that runs once records its states under the case *.
| State | Use when |
|---|---|
passed |
the expected result appeared |
failed |
something else appeared; record what was seen, quoting the screen |
blocked |
something outside the product is missing: data, an account, an integration; record what. With cases, a case whose data cannot be built gives blocked to every step of that pass, with the reason |
blocked-by-parent |
a parent is not passed in this run; the step is not executed. With role tracking on, a not-applicable parent gives not-applicable instead |
waived |
the user chose to skip it; record the reason in their words. On api, only when asked before the run: the test still runs, and its outcome is ignored |
not-applicable |
role tracking on: a parent ended with denied, hidden or read-only for this role, or is not-applicable for it, so the step has nothing to do. It is neither a failure nor a block |
no-account |
role tracking on, ui only: the role has no entry in the config accounts, or its env var names are missing from mcp.secretsFile. Every step of that role's pass gets this state. The role stays in the runbook's roles. anonymous never gets it, and api runs never get it, because a script authenticates through the project's test helper |
Placeholders
map writes one placeholder per flow no runbook cites, with its surface and the existing tests that fit the flow in scripts, from ${RUNBOOKS_PLUGIN_ROOT}/templates/placeholder.md: full frontmatter with status: placeholder, a title, # <title>, a Goal, and a Steps section with no steps. create fills it in place, keeping its path and id. A placeholder is never run, scripted or listed by --affected.
last_run
last_run holds one state per cell. A cell is a step, with a role when role tracking is on, a case when the runbook has cases, and a viewport when it has viewports. A step whose Roles:, Cases: or Viewports: line leaves a pass out has no cell for that pass.
last_run:
date: 2026-10-18
oldest: 2026-10-16
commit: "def5678"
result: passed
report: _runs/2026-10-18-0910-billing.issue-invoice.md
mixed: true
outdated: true
runs:
r1: { date: 2026-10-16, commit: "abc1234", report: _runs/2026-10-16-1432-billing.issue-invoice.md }
r2: { date: 2026-10-18, commit: "def5678", report: _runs/2026-10-18-0910-billing.issue-invoice.md }
cells:
S01: { admin: passed r1 outdated, viewer: passed r1 outdated }
S02: { admin: passed r2, viewer: passed r2 }runslists each report that still owns a cell, keyedr1,r2and so on, oldest first. The keys are renumbered on every write.cellshas one key per step. Its value maps a pass key to<state> <run key>, followed byoutdatedwhen the cell is outdated. The pass key is the role, then/<case>when the cell has a case, then@<viewport>when it has a viewport:admin,admin/vip-team,admin/*@mobile,"@mobile". A key that starts with@is quoted, because YAML does not allow a plain key to start with@:S03: { "@desktop": passed r1, "@mobile": failed r2 }. A cell with no role, case or viewport is written as the value itself:S02: passed r2. A pending cell is absent.resultisfailedwhen any cell failed, elseblockedwhen any isblockedorblocked-by-parent, elseincompletewhen any cell is pending, elsepassed.not-applicable,no-accountandwaivedcount as passing.mixed: truewhenrunshas more than one entry. It goes away when a full run rewrites every cell.date,commitandreportcopy the newest run.oldestis the date of the oldest run, andstaleages a runbook from it.outdated: truewhile any cell is outdated.- A
no-accountrow fills only a pending cell and never replaces a cell that ran. runbooks.mjs last-runwrites the shape,mark-outdatedsetsoutdated, andrename-caseandrename-role-cellsrename a case or role in the cells.last-run,mark-outdated,rename-role-cells,rename-caseanddelete-caseexit 2 and write nothing when alast_runthey would touch has a problem (a state, run key or pass value they cannot read), because the write would drop the cells they skipped.rename-role-cellschecks every runbook, and the two case commands check every runbook of the case's context. Fix the value by hand first.- The old one-line shape (
last_run: { date, commit, result, report }, withoutdated: trueat most) is still read.mark-outdatedkeeps it in that shape. The firstlast-runwrite converts it, and the cells it did not hold stay pending.
Status
mapwritesplaceholder.createwritesdraft, also when it fills a placeholder.- A full run (no
--steps, no--role, no--case), or a partial run with--mergethat leaves no cell pending or outdated, where every step endspassedorwaived, at least one ispassed, and none is in another state promotesdrafttoactive. Withviewports, this counts every state at every viewport. With role tracking on, it counts every role too, and every case with cases, andnot-applicableandno-accountalso allow the promotion. Compute it withrunbooks.mjs status. - The run
resultisfailedwhen any step failed, elseblockedwhen any step isblockedorblocked-by-parent, elsepassed.not-applicableandno-accountcount toward none of these. A partial run with--mergealso movesstatus, but only when no cell is pending or outdated. - An
activerunbook that later fails staysactive;last_runshows the failure. deprecatedrunbooks are listed but never count as coverage and never appear inlist --affected.- A runbook with frontmatter problems shows as
invalidand never counts until fixed.