Graph model, v1
Normative. Derived from RUNBOOK.md §6; where the two differ, RUNBOOK.md wins.
Implemented by @runbooks/graph; every rule here has a test in
packages/graph/src/invariants.test.ts.
A runbook's graph is the structural projection of profile P1. It has three consumers — the linter, the renderer and the editor — and exactly one definition, here. Graph invariants are the structural half of T1/T2 (§8): the same rules, applied to structure rather than to text.
Nodes
| Type | Source | Terminal |
|---|---|---|
start |
synthetic, exactly one | no |
check |
P1 step | no |
action |
P1 step | no |
decision |
P1 step | no |
wait |
P1 step | no |
escalate |
P1 step | yes |
end |
synthetic: success / failed / aborted |
yes |
Edges
| Edge | Semantics | Label |
|---|---|---|
next |
main flow | — |
branch |
a decision branch |
required, the key from branches |
on_fail |
the route when a check's expectation does not hold | — |
rollback |
an action to the step that undoes it | — |
approval |
a gate on arriving at a step | required, approval |
retry |
the only permitted cycle | required, the finite limit |
approval sits on the transition, not on the node. Every incoming edge to a step
with requires_approval: true is accompanied by an approval edge from the same
predecessor. Modelling it as a self-loop would make the gate a cycle, which invariant 6
would then reject — a gate is a property of arriving at a step, not of the step.
Derivation
steps[] → graph is deterministic: two implementations must produce the same graph from
the same document, or the linter and the renderer disagree about what they are looking
at.
- Nodes follow document order, with the synthetic
startfirst and any syntheticendnodes last. Nothing sorts by id — determinism comes from the document, so reorderingsteps[]is a real change and shows up as one. - A step with no
nexttakes the following step in document order. - A step with neither
nextnorbranchesand nothing following it terminates atend:success, made explicit rather than left as a hole. nextandon_failmay name an explicit terminal asend:success,end:failedorend:aborted.on_fail: escalate:sNnames the stepsN.
graph → steps[] is lossless: each node carries its source step verbatim. The graph is
a view over the document, not a replacement for it — the source of truth is the parsed
tree, not the picture (§18.3), and an engineer is right not to trust an editor that
loses what it did not think to model.
Blocking invariants
Each has a code that resolves to an anchor on this page, and a message that names the location and the fix (§18.7).
| # | Code | Rule |
|---|---|---|
| 1 | graph/start-not-unique, graph/unreachable |
Exactly one start; every node reachable from it. |
| 2 | graph/dangling-edge |
No edge points at a node that does not exist. |
| 3 | graph/decision-thin, graph/decision-not-exhaustive |
Every decision has ≥2 branches, each leading to a node, and a default. |
| 4 | graph/check-unrouted |
Every check has expect and a resolvable on_fail. |
| 5 | graph/destructive-unpaired |
Every destructive/irreversible action has an incoming approval edge or an outgoing rollback. |
| 6 | graph/unbounded-cycle |
Cycles only through a retry edge with a finite limit. |
| 7 | graph/implicit-terminal |
Every terminal is explicit; a graph may not end by running out of next node. |
Invariant 5 and cross-runbook rollbacks
A rollback_ref naming another runbook does not satisfy invariant 5 in v1.
Relations between runbooks are not in v1. Until they exist, a supervisor cannot resolve or route to such a rollback, so counting it would let a destructive step ship with a safety net nothing can deploy. The reference remains valid and is shown to a human as documentation — it is simply not a mitigation.
The schema is deliberately more permissive here, because it validates one step in isolation and cannot see whether a target exists. That is the general split: a schema error means this step is malformed; a graph error means this step does not fit the procedure around it.
Warnings
Non-blocking; they lower trust (§8).
| Code | Rule |
|---|---|
graph/too-many-nodes |
More than 12 steps and nothing decomposes it. Split part of it into its own record and link back with part_of, naming the step it expands (§18.6). Checked by the catalog rather than by the graph alone: whether a procedure was decomposed is a fact about other records. |
Layout
Direction is fixed and means the same thing on every graph in the catalog: the happy
path is a strictly vertical centre axis, on_fail and escalate go right, rollback
goes left. Consistency matters more than compactness, because the rule only buys
anything if a reader can rely on it without checking.
When a node is reached by both a rollback and an on_fail edge, it is laid out on
the rollback side. A check that fails routing to the step that undoes the previous
action is ordinary — and that node is the rollback; the failure edge merely reaches
it. Placing it right would put the undo where a reader looks for an escalation.
Auto-layout is canonical. x-layout is an authoring-time convenience, stripped on
publish, and it never participates in content_hash — re-laying-out a graph is not a
new version. Reasoning: docs/decisions/Q8-layout.md.
The schema therefore accepts x-layout on a step and the build drops it. Both
halves are needed: rejecting the field would make the constructor's own output
unpublishable, and keeping it would give two readers of one version two different
graphs.