
# The Graph Drawing

> The graph component — nodes laid out in ordered columns or in lanes of chained steps, with the edges running between them.

The other way to read the same graph a `matrix` reads. Where a matrix crosses two sets and draws what sits at each intersection, a graph draws the **connections themselves**. A pair with no edge is a visible empty cell in a matrix; in a graph it is simply nothing.

Reach for it when the question is _how_ something is reached rather than _whether_ it is — a path a reader can follow with a finger, through whatever stands in the middle. Reach for a `matrix` when the answer is a lookup, and for a `chart` when the answer is a quantity: no `chartType` draws nodes and edges, because every one of them plots a value against an axis.

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `layout` | enum | `layered`, `lanes` |  | How the drawing places its nodes (default: 'layered' — ordered columns, edges between them). `lanes` draws one row per lane node followed by its chain of stations. Closed because the renderer draws each layout, and it draws these two; a value it cannot draw is refused at boot naming the value. |
| `legend` | boolean |  |  | Draw the key mapping each shape to the node kind it stands for (default: false). Rendered INSIDE the drawing, so it is `aria-hidden` exactly when the drawing is. Its entries are derived from the kinds the declared columns admit, never authored, so it cannot disagree with what is drawn. |
| `label` | string |  |  | Accessible name for the drawing. Present: the drawing is role="img" with this as its aria-label. Absent: the drawing is aria-hidden and the accessible twin beneath it carries the facts alone. The twin renders either way. |
| `emptyMessage` | string |  |  | Message displayed when the bound graph places no node in any declared column. Not used for a DEGRADED read, where a source could not be resolved and the drawing is missing its contribution rather than empty. |

### `dataSource`

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `dataSource` | object |  |  | Graph read-endpoint binding for the node-link drawing |
| `dataSource.system` | object |  |  | Graph read-endpoint binding: the nodes are placed into the declared columns and the edges are drawn between them |
| `dataSource.system.endpoint` | string |  |  | Read endpoint path the drawing fetches its graph from, with the caller’s own credentials (e.g. /api/admin/organisation/graph) |
| `dataSource.system.nodesKey` | string |  |  | Key of the nodes array in the response envelope (default: 'nodes'). Nodes are placed into the declared columns. |
| `dataSource.system.edgesKey` | string |  |  | Key of the edges array in the response envelope (default: 'edges'). An edge is drawn only when BOTH its endpoints were placed into a column. |
| `dataSource.system.query` | object |  |  | Static query params merged into every request to the endpoint |

### `autoSave`

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `autoSave` | object |  |  | Configuration for automatic persistence of edits. Applies to table, form, kanban, and calendar components. |
| `autoSave.saveMode` | enum | `auto`, `onBlur`, `manual` |  | Save trigger strategy: 'auto' (debounced), 'onBlur' (field blur), 'manual' (button). Default: 'manual'. |
| `autoSave.autoSaveDebounceMs` | number |  |  | Debounce delay for auto-save in milliseconds (default: 500, min: 100) |
| `autoSave.showSaveIndicator` | boolean |  |  | Display a save status indicator (Saving... / Saved / Error). Default: true when saveMode is auto or onBlur. |
| `autoSave.saveIndicatorPosition` | enum | `inline`, `toast`, `toolbar` |  | Where the save status indicator appears |

### `search`

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `search` | object |  |  | Shared search bar configuration for data-bound components (table, kanban, calendar) |
| `search.enabled` | boolean |  |  | Enable search bar (default: true) |
| `search.placeholder` | string |  |  | Search input placeholder text |
| `search.debounceMs` | number |  |  | Debounce delay for search input in ms (default: 300) |
| `search.highlight` | boolean |  |  | Highlight matched search terms in results (default: false) |

### `columns`

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `columns` | array |  |  | The drawing’s columns, in left-to-right order. Every node the bound graph returns is placed into the FIRST column whose `kinds` admit it; a node no column admits is not drawn, and an edge is drawn only when both its endpoints were. Declared empty is refused — see the check below. |
| `columns[]` | object |  |  | One column of the layered drawing: which node kinds it admits, the heading it carries, and the node fields it bands and orders by. The array’s order is the drawing’s left-to-right order. |
| `columns[].kinds` | array (truncated) |  |  | Node kinds this column admits, as the BOUND GRAPH spells them — open strings, never a platform vocabulary. Required: in an ordered partition, a column with no filter would draw every node a second time, which is a config nobody means. |
| `columns[].label` | string |  |  | The column’s heading, used by the drawing and as the group name in the accessible twin. Required alongside `kinds`: an unnamed column is an unnamed group in the twin, and naming it after its kinds would leak the endpoint’s vocabulary into the operator’s reading. |
| `columns[].groupBy` | string |  |  | Node field whose value bands this column into labelled groups (e.g. a resource family). Names a field of the bound graph’s nodes, so it is an open string. Nodes that do not carry it are placed in the column ungrouped, after the bands. |
| `columns[].sortBy` | string |  |  | Node field this column orders by. Names a field of the bound graph’s nodes, so it is an open string; nodes that do not carry it keep their source order after those that do. Omitted, the column is drawn in the order the endpoint emitted. |
| `columns[].sortDirection` | enum | `asc`, `desc` |  | Sort order: ascending or descending |

### `lanes`

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `lanes` | object |  |  | A lanes drawing: which node kinds define a lane, the heading the spine carries, and the station track each lane’s chain is drawn along. Read only when `layout` is `lanes`; declaring it beside `columns` is refused at boot. |
| `lanes.kinds` | array |  |  | Node kinds that DEFINE a lane, as the BOUND GRAPH spells them — open strings, never a platform vocabulary. One row is drawn per node admitted here, and its stations are the chain reachable from it. |
| `lanes.kinds[]` | string |  |  | One lane node kind, as the bound graph spells it |
| `lanes.label` | string |  |  | The lane spine’s heading, drawn above the identity gutter. Required alongside `kinds` for the reason a column’s label is: an unnamed spine is an unnamed group in the accessible twin. |
| `lanes.stations` | object |  |  | The station track of a lanes drawing: which node kinds are stations, and the heading the track carries. Their ORDER is the graph’s — the chain reachable from the lane node — so there is no key to sort them by. |
| `lanes.stations.kinds` | array (truncated) |  |  | Node kinds this track admits as STATIONS, as the BOUND GRAPH spells them — open strings, never a platform vocabulary. A lane’s stations are the chain of these reachable from its lane node, in the order the edges put them; no edge kind is named, because the only edges that can match are the ones arriving at a station. |
| `lanes.stations.label` | string |  |  | The station track’s heading, drawn once above the track. Required alongside `kinds` for the reason a column’s label is: an unnamed track is an unnamed group in the accessible twin, and naming it after its kinds would leak the endpoint’s vocabulary into the operator’s reading. |
| `lanes.sortBy` | string |  |  | Node field the LANES are ordered by, top to bottom. Names a field of the bound graph’s nodes, so it is an open string; lanes not carrying it keep their source order after those that do. Omitted, the lanes are drawn in the order the endpoint emitted. There is deliberately no equivalent for STATIONS — their order is the chain the edges describe. |
| `lanes.sortDirection` | enum | `asc`, `desc` |  | Sort order: ascending or descending |

### `selection`

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `selection` | object |  |  | Makes the drawing’s nodes selectable and says which way the reach highlight walks from the selected one. Absent: the drawing is not selectable. The highlight is internal state — only the selected node’s id reaches the shared-filter bus. |
| `selection.mode` | enum | `single` |  | How many nodes may be selected at once. Closed at `single` because the renderer implements one selection behaviour — `multiple` would leave the reach set ambiguous (union or intersection) and is not defined. |
| `selection.reach` | enum | `downstream`, `upstream`, `both` |  | Which way the reach highlight walks the edges from the selected node (default: 'downstream' — what the selection reaches). `upstream` is the mirror question, `both` the whole connected component. Closed because a direction is platform vocabulary; the edges it walks are not. |

### `publishes`

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `publishes` | object |  |  | Marks a choice control as a shared-filter publisher: its current value is published on the named channel under `param`, and every data source whose bindTo names that channel merges it into each request. |
| `publishes.bindTo` | string |  |  | Shared-filter channel id this control publishes on (the string a subscriber's bindTo names) |
| `publishes.param` | string |  |  | Request-param key this control's value is published under |

`layout` is `layered` by default — ordered columns — or `lanes`, one row per chain, and it picks which of `columns` and `lanes` is read. Every property is optional, including the one your `layout` calls for: a graph that declares `layered` and no `columns` renders its empty state rather than stopping your app booting.

Declaring **both** `columns` and `lanes` is the one combination refused outright, by name, when your app boots. Only one of them can be read, so the other would disappear without a trace: you would have written a full three-column partition, looked at a page that seems entirely correct, and had nothing anywhere point at why your columns were not on it.

## Binding a graph

The same binding a `matrix` takes, for the same reason: a graph is two collections that address each other by id. `endpoint` is required; `nodesKey` and `edgesKey` default to `nodes` and `edges`, and `query` merges static parameters into every request. Because the read runs as the visitor, they only ever see the part of the graph they could have read themselves.

## How a node is drawn

Two things about a node are legible before its name is: its **shape** and its **weight**. Neither is yours to configure, and both come from the endpoint — so this holds whichever layout placed the node.

Shape carries the **kind**. Shapes are assigned per drawing, in the order the kinds first appear, which is the whole reason `legend` exists: the built-in organisation graph alone emits twelve node kinds, and twelve shapes are not learnable from the figure on their own.

Weight carries the **state**. A node your endpoint reports as `paused` is drawn dashed, one reported `disabled` is drawn muted, and anything else is drawn at rest. Colour is part of neither — kind is shape, state is weight, and colour stays with your theme's own tokens. `paused` and `disabled` are your **endpoint's** words, exactly as the values in `kinds` are, and a state Sovrium does not recognise is drawn at rest rather than being an error.

**A node reporting no state is not a node reporting `active`.** An endpoint that can report state emits `active` explicitly, and omits the key entirely when the source it would have read was unavailable. A node drawn at rest therefore means _this read could not tell_, which is not the same as _this thing is running_.

## The columns

`columns` is an **ordered partition**. Each entry names the node kinds it admits, every node is placed into the first column that admits it, and the array's order is the drawing's order from left to right — which is why this is a list rather than a pair of named axes.

`kinds` and `label` are both required on a column: `kinds` admits node kinds as the endpoint spells them, and `label` is the heading and the group name in the accessible twin. `groupBy` bands the column into labelled groups, `sortBy` orders it, and `sortDirection` is `asc` by default.

The two are required **together** because they are the two halves of a named partition. A column with no `kinds` would admit everything and draw every node a second time, connected to itself; a column with no `label` would leave an unnamed group in the table beneath. Naming a column after the kinds it admits is the tempting alternative and it is worse: `person, agent` is your endpoint's vocabulary, where `Principals` is the word you meant.

**A label may be a translation key.** `$t:graph.principals` works in any label you author on a graph or a matrix — a column's, a lane's, one of its stations', or a matrix `flag.label` — and resolves in the page's active language. It is resolved on the server, before the drawing and its accessible twin are composed, so the two never disagree about which language they are in.

Declare `sortDirection` even when the endpoint already returns the order you want. A column drawn in whatever order the response happened to arrive in reads correctly right up until the endpoint changes, and nothing will tell you when that was.

**An edge is drawn if and only if both its endpoints are.** That single rule is the entire edge filter: point the columns at the node kinds you want and the edges follow, so a graph whose columns admit no automation node draws no automation step without your having said so. There is no separate edge list to keep in step with the columns, and therefore no way for the two to disagree.

## The lanes

Set `layout: lanes` and the drawing changes shape. Instead of partitioning every node across columns, you name **one kind of node that starts a chain** — an automation, a pipeline, a run — and each one gets a row of its own: the node on the left, then the steps leading out of it, running to the right.

`lanes.kinds` and `lanes.label` are required, as is `stations`, itself `{ kinds, label }` with both required — the chain drawn along each lane. `sortBy` orders the **lanes** top to bottom, and without it they arrive in the endpoint's order.

```yaml
- type: graph
  dataSource:
    system: { endpoint: /api/admin/organisation/graph }
  layout: lanes
  lanes:
    kinds: [automation]
    label: Mechanism
    sortBy: label
    stations:
      kinds: [step]
      label: Steps, in order
  selection: { mode: single, reach: downstream }
  label: What each automation does, in order
```

**There is deliberately no `sortBy` for the steps.** A lane's steps are already in order, because the edges say so: the endpoint runs an edge from the lane node to its first step and one from each step to the next, and walking that chain forward _is_ reading the order. A sort key for the steps would hand you a second, independent answer to a question the graph has already answered — and sooner or later the two would disagree, leaving a drawing that has to pick one and a configuration that looks correct either way.

The walk names no edge kind either, for the same reason the edge rule names none: the only edges that can arrive at a step are the ones running along a chain. Pointing `stations.kinds` at your endpoint's step nodes is the whole of the filtering.

## Selecting a node

`selection.mode` is required and takes `single` — one node at a time. `reach` is `downstream` by default, or `upstream`, or `both`.

With `selection` declared, every node is focusable in reading order and `Enter` or `Space` selects the focused one. Selecting lights the **reach set** — everything the selection reaches, walking the edges in the `reach` direction — and dims everything else. `downstream` answers _what does this reach_, `upstream` answers _what reaches this_, and `both` is the whole neighbourhood. Omit `selection` and the drawing is a static figure: nothing takes a tab stop and nothing is ever marked.

`publishes` takes the same `{ bindTo, param }` a `select` takes: the selected node's id is published on the named channel under `param`, and any data source bound to that channel merges it into its next request. Only the id crosses — the reach highlight is the drawing's own state, and a subscriber has no use for it.

## Accessibility and rendering

The same contract as a matrix, for the same reason. A graph always renders an **accessible twin** — a real table listing every node drawn, its kind, the column or lane it sits in, and the nodes it reaches. Nothing switches it off. Under `lanes` that last column earns its place twice over: because each step's edge runs to the next one, the nodes a step reaches **are** the rest of its chain, so the table states the running order in words without anyone having to see a line.

`label` set makes the drawing one named figure with `role="img"`; omitted, the drawing is hidden from assistive technology. The twin renders either way. `legend` draws the key **inside** the drawing, so it is hidden exactly when the drawing is — a key to a figure nobody is being shown is noise, and the twin names every kind in words anyway.

Here a graph and a matrix differ, and it is worth knowing which half you are paying for. A matrix is drawn entirely on the server. A graph's **drawing** is a lazy island, because selecting a node is something that happens after the page has arrived. Its **accessible twin, its empty state and its degraded notice are all server-rendered**, in the first response, exactly as a matrix's are. A reader with scripting off still receives the table and every fact in it; what they do not receive is the ability to select.

## Behaviour

### Accessible twin

- `label` present: the drawing is `role="img"` carrying it as the accessible name
- `label` absent: the drawing is `aria-hidden` and exposes no accessible name
- The twin is a real table and renders in BOTH states, never `aria-hidden`
- The twin has exactly one row per DRAWN node, naming the column that node sits in
- Each twin row names the nodes its node reaches — the edges exist as text, not only as lines
- Every twin row is keyboard-reachable
- `legend: true` draws the key inside the drawing, hidden exactly when the drawing is
- The same map read twice — once as a named figure, once as the table alone — carries the same facts

### Column rendering

- `type: 'graph'` decodes, the page boots, and the drawing mounts as an island
- One column renders per declared `columns[]` entry, in the DECLARED order
- A column admits exactly its `kinds`; a node no column admits is not drawn at all
- `groupBy` bands a column, one band per distinct value of that node field
- `sortBy` + `sortDirection: desc` orders a column most-privileged first, not in source order
- An edge is drawn exactly when BOTH its endpoints were drawn, and never otherwise
- The read carries the CALLER's credentials: a member gets no drawing, not somebody else's
- An operator opens the page and reads a banded, ordered map of their own configuration

### Empty and degraded reads

- No column admitting a node renders `emptyMessage` and no drawing
- A degraded read names each unresolved source and is not drawn as empty-but-healthy
- An edge whose endpoint is absent from the body is dropped, and the drawing renders

### Lane rendering

- `layout: 'lanes'` draws one lane per admitted lane node, and no column anywhere
- Each lane holds exactly its OWN stations, in the order the edge chain puts them
- A lane's state reaches the drawing and tells `active`, `paused` and `disabled` apart
- An unreadable pause ledger leaves the state ABSENT — a lane is not drawn as healthy
- `layout: 'lanes'` declaring no `lanes` renders the empty state, and the app still boots
- The twin carries the chain as text: each row names its lane and the station it reaches next
- Selecting a lane marks its whole chain and dims every other lane
- A lane sub-line longer than the gutter is held inside it, never drawn across the station track
- An operator reads what each automation does, in order, and which ones are not running

### Schema refusals

- An empty `columns` array is refused at boot
- A column with an empty `kinds` list is refused rather than read as "admit nothing"
- A column with `kinds` and no `label` is refused at boot
- An empty `dataSource.system.endpoint` is refused at boot, naming the offending path
- A DB-table `dataSource` is refused rather than decoding and drawing nothing
- A `publishes` carrying `bindTo` and no `param` is refused at boot
- A `layout` the renderer cannot draw is refused at boot, naming the value
- A closed-set value outside its set is refused wherever one is declared
- A `lanes` carrying a spine and no station track is refused at boot
- A graph declaring both `columns` and `lanes` is refused at boot, naming both keys
- Every mis-declaration is met at boot, and the valid config beside it still draws

### Selection and reach

- With `selection` declared, nodes are focusable in DOM order
- `Enter` and `Space` each select the focused node
- Selecting marks every node and edge in the downstream reach set
- Selecting dims every node and edge OUTSIDE the reach set
- `reach: 'upstream'` walks the mirror direction from the same node
- Selecting publishes the node id under `publishes.param`, and the reach set crosses nothing
- A reader selects a principal, reads what it reaches, and selects a resource to invert it
