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.

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.