# The `.pumapack` file format (PumaFlow, schema 1)

This document describes the files PumaFlow exports and imports, in enough
detail to **edit an export** or **generate a flow from scratch** so that it
imports cleanly: no warnings, no renumbered ids, no dropped connectors. It is
written for a reader, human or AI, who has no access to the app's source.

PumaFlow's own files are plain UTF-8 JSON. They are saved with a `.json`
extension, and the Import control accepts both `.json` and `.pumapack`. The
app imports a file in any of three ways:

- the topbar **Import** button;
- `Cmd+O` / `Ctrl+O`;
- dropping the file anywhere on the window.

All three do exactly the same thing.

---

## 1. The short version

If you only read one section, read this one.

1. There are two file shapes (§2). A **workspace** file holds every flow and
   **replaces** the user's whole workspace after they confirm. A **single
   flow** file **adds** one flow as a new tab. Edit whichever shape you were
   given. To hand over a brand-new flow, the single flow shape is the gentler
   choice, because it cannot overwrite anything.
2. A workspace file must have `"format": "pumaflow-workspace"` and a
   non-empty `tabs` array. A single flow file must have a `nodes` array.
3. **Every id is a string.** Numeric ids are thrown away and replaced, and
   every connector that pointed at them is silently deleted. See §8.
4. Every edge's `from` and `to` must be the `id` of a node **in the same
   flow**. An edge that points anywhere else is dropped without a word.
5. Every node's `lane` is a lane `id` in the same flow, or `null`. Put the
   node's center inside that lane's band on the canvas (§6.3).
6. Positions and sizes are **numbers**, in canvas pixels, with `y` growing
   downward. A number written as a string becomes `0`.
7. Enum values must be spelled exactly as listed in §4. A wrong value is
   replaced by the default with no warning (a misspelled node `type` becomes
   `"process"`).
8. Unknown keys are dropped everywhere. There is nowhere to stash extra data.
9. Check the result against the checklist in §10.

§11 is a complete, valid example you can copy and adapt.

---

## 2. The two file shapes

### 2.1 Workspace file (the topbar Export)

This is what the topbar **Export** button and `Cmd+S` / `Ctrl+S` write: every
flow, plus the user's display preferences. It is the file a user most
naturally exports and hands over.

```json
{
  "format": "pumaflow-workspace",
  "version": 1,
  "app": "PumaFlow",
  "exported": "2026-10-28T09:00:00.000Z",
  "theme": "dark",
  "activeTabId": "tab_access",
  "tabs": [ { "...one flow object, see §3..." } ],
  "exportTheme": "light",
  "grid": "dots",
  "snap": "0",
  "accent": null,
  "versions": null
}
```

| Key | Value | Notes |
|---|---|---|
| `format` | `"pumaflow-workspace"` | **Required, exactly this string.** It is how the importer tells a workspace from a single flow. |
| `version` | `1` | The file format version. Not checked on import. |
| `app` | `"PumaFlow"` | Not checked. |
| `exported` | ISO 8601 datetime | Informational. Ignored on import. |
| `theme` | `"dark"` or `"light"` | The app's color theme. Anything else becomes `"dark"`. |
| `activeTabId` | a flow `id` | The flow shown after import. If it matches no flow, the first flow is shown. |
| `tabs` | array of flow objects | **Required and non-empty.** Array order is tab order. See §3. |
| `exportTheme` | `"light"`, `"dark"` or `"auto"` | Theme used for SVG and PNG exports. |
| `grid` | `"dots"`, `"lines"` or `"off"` | Canvas background grid. |
| `snap` | `"1"` (on) or `"0"` (off) | Snap-to-grid when dragging. **A string, not a boolean.** |
| `accent` | `"#rrggbb"` or `null` | The app's accent color. |
| `versions` | array or `null` | Named restore points the user saved in the browser. Leave it exactly as exported, or write `null`. See §7. |

For `exportTheme`, `grid`, `snap`, `accent` and `versions`, **`null` means
"leave the importing browser's own setting alone"**. That is the safe value
for a generated file.

What the importer does with a workspace file:

- It shows a dialog titled **"Restore workspace?"** explaining that the
  user's current flows will be replaced, with **Cancel** and **Replace
  workspace** buttons. Nothing changes until the user clicks Replace
  workspace.
- On Replace workspace it swaps in every flow from the file and says
  *"Workspace restored — 1 flow"* (or *"Workspace restored — 3 flows"*). The user can undo it
  immediately with `Cmd+Z` / `Ctrl+Z`.
- If `tabs` is an empty array it refuses: *"That workspace file has no
  flows"*.

### 2.2 Single flow file (right-click a tab, Export JSON)

Right-clicking a flow's tab and choosing **Export JSON** writes one flow,
named after its title. Its keys are the flow object's keys from §3, minus
`id`, plus `format` and `version`:

```json
{
  "format": "pumaflow",
  "version": 1,
  "title": "Access request",
  "nodes": [ ],
  "edges": [ ],
  "lanes": [ ],
  "laneOrigin": 0,
  "laneOrient": "v",
  "annotations": [ ],
  "groups": [ ],
  "viewport": { "x": 0, "y": 0, "zoom": 1 }
}
```

What the importer does with a single flow file:

- It is recognized by having a `nodes` **array**. `format` and `version`
  are not checked, but write them.
- It is **added** as a new tab after the existing ones and becomes the active
  tab. Nothing existing is touched and there is no dialog.
- The success message is *"Imported flow"*.
- The flow gets a **new random tab id**. Node, edge, lane, annotation and
  group ids are kept.
- If `title` is empty or missing, the file name (without extension) is used.

### 2.3 What the importer refuses

The importer tries the shapes in this order: workspace, then a cross-app
pack (§2.4), then single flow, then a Mermaid diagram. If none fits it says:

> *"Couldn't import: not a PumaFlow JSON or Mermaid file"*

That is what you get for invalid JSON, for a workspace file with `format`
missing or misspelled, for a single flow whose `nodes` is not an array, and
for a flow wrapped in some other envelope such as `{ "data": { "nodes": … } }`.

### 2.4 Other inputs (not covered here)

- **Mermaid.** A Mermaid `flowchart` or `graph` text file imports as a new,
  automatically laid-out flow. If part of it cannot be read, the message
  says how many items were skipped.
- **Packs from other PumaWorx apps.** A file with a `puma.app` field and a
  `data.nodes` array (for example a PumaMapper mindmap) imports as a new
  flow, with every node a plain process step, laid out automatically. A pack
  from an app with no `data.nodes` is refused by name, for example *"That's a
  PumaKeeper pumapack — PumaFlow needs nodes + edges. Open it in PumaKeeper
  instead."* Those apps' formats are documented by those apps.

Neither of these carries shapes, lanes, colors or metadata, so to produce a
real PumaFlow diagram use §2.1 or §2.2.

---

## 3. The flow object

One flow is one tab. In a workspace file each entry of `tabs` looks like this:

```json
{
  "id": "tab_access",
  "title": "Access request",
  "nodes":       [ ],
  "edges":       [ ],
  "lanes":       [ ],
  "laneOrigin":  -240,
  "laneOrient":  "v",
  "annotations": [ ],
  "groups":      [ ],
  "viewport":    { "x": 0, "y": 0, "zoom": 1 }
}
```

| Field | Type | Notes |
|---|---|---|
| `id` | string | Unique among the file's flows. Only meaningful in a workspace file (for `activeTabId`). |
| `title` | string | The tab's name, and the title of the Markdown and PDF specs. |
| `nodes` | array | The boxes. See §4.1. |
| `edges` | array | The connectors. See §4.2. |
| `lanes` | array | Swimlanes, in order. `[]` for none. See §4.3. |
| `laneOrigin` | number | Where the first lane starts on the canvas. See §6.3. `0` when there are no lanes. |
| `laneOrient` | `"v"` or `"h"` | `"v"`: lanes are columns side by side. `"h"`: lanes are rows stacked top to bottom. Anything else becomes `"v"`. |
| `annotations` | array | Sticky-note callouts. See §4.4. |
| `groups` | array | Labeled dashed boxes drawn behind nodes. See §4.5. |
| `viewport` | `{x, y, zoom}` | Pan and zoom. The app reframes the flow it opens on import, so this is only kept for flows that are not active. `zoom` is clamped to 0.25–3. |

---

## 4. Record shapes

### Conventions for every record

- **`id` is a non-empty string**, unique within its own array in the flow.
  The app generates ids like `n_1ikeslk1cm5ccz`; short readable ids (`n1`,
  `n_start`, `lane_ops`) work just as well. Ids need not be unique across
  flows.
- **Missing or wrongly typed fields fall back to the defaults shown below.**
  `null` is safe almost everywhere, because every field is type-checked on
  the way in. The two exceptions are in §9.
- **Colors** are one of seven names: `"red"`, `"amber"`, `"green"`,
  `"blue"`, `"violet"`, `"teal"`, `"gray"`, or `null` for the default. Hex
  values are not accepted and become `null`.
- **Coordinates** are canvas pixels. `x` and `y` are the **top-left corner**
  of a box; `y` grows downward. Negative values are fine.

### 4.1 `nodes[]`

```json
{
  "id": "n_approve", "type": "decision", "text": "Manager approves?",
  "x": 45, "y": 100, "w": 150, "h": 100,
  "color": "amber", "note": "", "lane": "lane_desk",
  "meta": {
    "owner": "Line manager", "system": "Service portal", "duration": "1 day",
    "inputs": "Access ticket", "outputs": "Decision",
    "raci": { "r": "Line manager", "a": "Line manager", "c": "", "i": "Requester" },
    "links": [ { "label": "Access policy", "url": "https://intranet.example.com/policies/access" } ]
  }
}
```

| Field | Type | Default | Meaning |
|---|---|---|---|
| `id` | string | a new random id | See conventions. **A non-string id is replaced**, which disconnects every edge that used it. |
| `type` | shape id (table below) | `"process"` | The shape. `"start"` and `"end"` are accepted and become `"terminator"`. |
| `text` | string | `""` | The label drawn inside the shape. It wraps to the box width. |
| `x`, `y` | number | `0` | Top-left corner. |
| `w`, `h` | number | the type's default size | Width 40–900 and height 30–700; values outside are clamped. |
| `color` | color name or `null` | `null` | Fill and outline tint. |
| `note` | string | `""` | Free-text step detail. Shown in the node's properties panel, searched by Find, and printed in the Markdown and PDF specs. |
| `lane` | lane id or `null` | `null` | Which swimlane the node belongs to. Cleared to `null` if no lane in this flow has that id. See §6.3. |
| `meta` | object | all empty | Process documentation. See below. |

Node shapes, with the size the app gives a new node of each type:

| `type` | Shown as | Default `w` × `h` |
|---|---|---|
| `terminator` | Start / End | 130 × 56 |
| `process` | Process | 160 × 70 |
| `decision` | Decision | 150 × 100 |
| `io` | Input / Output | 160 × 70 |
| `document` | Document | 160 × 78 |
| `subprocess` | Subprocess | 170 × 74 |
| `database` | Database | 150 × 88 |
| `cloud` | Cloud / external | 170 × 98 |
| `manualInput` | Manual input | 160 × 72 |
| `display` | Display | 170 × 78 |
| `delay` | Delay / wait | 150 × 64 |
| `preparation` | Preparation | 160 × 84 |

Note the capital `I` in `manualInput`. Any other spelling becomes
`"process"`.

**`meta`** holds the per-step documentation the ⓘ badge shows and the
Markdown and PDF specs print. Every field is free text:

| Field | Type | Meaning |
|---|---|---|
| `owner` | string | Who owns the step (a person or a role). |
| `system` | string | The system or tool it happens in. |
| `duration` | string | How long it takes, as the reader should see it: `"5 min"`, `"1 day"`. Not parsed. |
| `inputs`, `outputs` | string | What goes in and what comes out. |
| `raci` | `{ "r", "a", "c", "i" }` | Four strings: Responsible, Accountable, Consulted, Informed. Names or roles; several can share one string (`"Ana, Tom"`). No rule is enforced. |
| `links` | array of `{ "label", "url" }` | Related documents. A link with neither a label nor a URL is dropped. |

A link's `url` must use `http:`, `https:`, `mailto:`, `tel:` or `file:`, or
have no scheme at all (a relative path). Any other scheme, `javascript:` for
example, is **blanked to `""`**; the link is kept if it has a label.

The ⓘ badge appears on a node when any `meta` field is non-empty. For a node
with no documentation, write every field empty as shown in §11.

### 4.2 `edges[]`

```json
{
  "id": "e3", "from": "n_approve", "to": "n_grant",
  "fromAnchor": "bottom", "toAnchor": "top",
  "label": "Yes", "style": "thick", "color": "green",
  "arrows": "end", "routing": "ortho", "loopSide": "right",
  "waypoint": null, "fromOrder": null, "toOrder": null
}
```

| Field | Type | Default | Meaning |
|---|---|---|---|
| `id` | string | a new random id | |
| `from`, `to` | node id | none | **Must both be node ids in this flow**, or the edge is dropped. `from` equal to `to` is a self-loop. |
| `fromAnchor`, `toAnchor` | `"auto"`, `"top"`, `"right"`, `"bottom"`, `"left"` | `"auto"` | Which side of the node the line leaves or enters. `"auto"` lets the app pick the side facing the other node. |
| `label` | string | `""` | Text on the line, drawn at its midpoint. Use it on every branch leaving a decision (`"Yes"`, `"No"`). |
| `style` | `"solid"`, `"thick"`, `"dashed"`, `"dotted"`, `"dashdot"` | `"solid"` | Line style. |
| `color` | color name or `null` | `null` | Line color. |
| `arrows` | `"none"`, `"start"`, `"end"`, `"both"` | `"end"` | Arrowheads. `"end"` points at `to`. |
| `routing` | `"ortho"`, `"straight"`, `"curved"` | `"ortho"` | Right-angled elbows, a direct line, or a smooth curve. |
| `loopSide` | `"right"`, `"left"`, `"top"`, `"bottom"` | `"right"` | Self-loops only: which side of the node the loop bulges out of. |
| `waypoint` | `{ "x", "y" }` or `null` | `null` | A bend point the line is routed through, in canvas pixels. Ignored on self-loops. `null` lets the app route it. |
| `fromOrder`, `toOrder` | number or `null` | `null` | Manual ordering of several lines that meet the same side of a node. Written by the app when the user drags an endpoint along a side. Leave `null`. |

Array order has no visible meaning, but keep it stable when editing.

### 4.3 `lanes[]`

```json
{ "id": "lane_desk", "title": "Service desk", "color": null, "width": 240 }
```

| Field | Type | Default | Meaning |
|---|---|---|---|
| `id` | string | a new random id | Referenced by node `lane`. |
| `title` | string | `"Lane"` | The lane header, usually a role or department. |
| `color` | color name or `null` | `null` | Tints the lane. |
| `width` | number | `240` | The lane's size across its band: its width for `"v"` lanes, its height for `"h"` lanes. Clamped to 130–1200. |

**Array order is lane order**: left to right for `"v"`, top to bottom for
`"h"`. The other dimension of every lane is not stored; the app stretches all
lanes to cover whatever nodes there are. See §6.3.

### 4.4 `annotations[]`

```json
{ "id": "ann_sla", "x": 260, "y": 100, "w": 200, "h": 90,
  "text": "Approvals older than three days are escalated.", "color": "amber" }
```

A free-floating note drawn above the nodes. It is not attached to anything.

| Field | Type | Default |
|---|---|---|
| `id` | string | a new random id |
| `x`, `y` | number | `0` |
| `w`, `h` | number | `200` × `90`; at least 80 × 36, at most 4000 |
| `text` | string | `"Note"` |
| `color` | color name or `null` | `null` |

### 4.5 `groups[]`

```json
{ "id": "grp_fulfill", "x": 20, "y": 235, "w": 200, "h": 235,
  "text": "IT fulfillment", "color": "teal" }
```

A labeled dashed box drawn behind everything except lanes. It **does not own
nodes**: membership is purely visual, so make the box enclose the nodes you
mean it to hold.

| Field | Type | Default |
|---|---|---|
| `id` | string | a new random id |
| `x`, `y` | number | `0` |
| `w`, `h` | number | `320` × `220`; at least 100 × 70, at most 4000 |
| `text` | string | `"Group"` (the label drawn on the top edge) |
| `color` | color name or `null` | `null` |

---

## 5. Cross-references

Every reference stays inside one flow and points at an `id`:

| From | Field | To | If it matches nothing |
|---|---|---|---|
| edge | `from`, `to` | `nodes[].id` | The edge is dropped. |
| node | `lane` | `lanes[].id`, or `null` | Set to `null` (unassigned). |
| workspace | `activeTabId` | `tabs[].id` | The first flow is shown. |

Annotations and groups reference nothing, and nothing references them.

Duplicate ids within one array are **not** detected. Do not write them: an
edge to a duplicated node id attaches to only one of the two.

---

## 6. Layout and semantics

### 6.1 Positions are kept exactly

A PumaFlow file is imported with every coordinate exactly as written. Nothing
is moved, snapped or auto-laid-out. (Only Mermaid and cross-app packs are laid
out automatically.) So a generated flow must place its own nodes. If the
result looks cramped, the user can press **Tidy** (`L`) in the app to re-lay
it out.

Practical spacing that reads well:

- Use the default sizes from §4.1.
- Leave about 40–60 px between boxes vertically and 60–80 px horizontally, so
  line labels have room.
- Flow top to bottom: start at the top, end at the bottom. Put a decision's
  branches side by side below or beside it.

### 6.2 What is derived, never stored

- **The step order and numbering** in the Markdown and PDF specs. The app
  walks the graph from the nodes with no incoming edge (start points first),
  breadth first, breaking ties top to bottom and then left to right. Nodes
  it cannot reach are listed last. When there are lanes, steps are grouped
  under each lane's title in lane order, with an "Unassigned" group for nodes
  with no lane.
- **Line geometry**: where a line leaves and enters, how it bends around
  shapes, and the small bridges drawn where lines cross.
- **The lane rectangles** (below).

### 6.3 Swimlanes

A node's lane is the `lane` field, not where it sits. But the lanes are drawn
at fixed positions, so a node placed outside its lane's band looks wrong, and
the first time the user drags it the app reassigns it to whichever lane its
center lands in. **Put every node's center inside its own lane's band.**

For `"laneOrient": "v"`, lanes are columns along the x axis. Lane *k* (from 0)
covers:

```
from  x = laneOrigin + (sum of the widths of lanes 0 … k-1)
to    x = that + lanes[k].width      (not including the end)
```

For `"h"`, the same arithmetic applies on the y axis.

Example: `laneOrigin` is `-240` and both lanes are `240` wide. Lane 0 covers
x from -240 up to 0, and lane 1 covers 0 up to 240. A 160-wide node centered
in lane 1 has `x` = 120 − 80 = 40.

The other dimension is automatic: every lane runs from a little above the
highest node to a little below the lowest, with a 38 px header strip at the
top (or the left, for `"h"`). You never write it.

---

## 7. Editing an existing export

- **Keep every id as it is.** Edges point at node ids and nodes point at lane
  ids; renaming one without the other disconnects them.
- **Add** a node, edge, lane, annotation or group by appending a record with a
  new, unique string id and every field from §4.
- **Delete** a node by removing it **and** every edge whose `from` or `to` is
  its id (the importer would drop them anyway, but the file should say what it
  means).
- **Keep `format` exactly as exported.** Without it, a workspace file is
  refused.
- **Leave `versions` alone, or set it to `null`.** It holds complete earlier
  copies of the workspace, stored as text. Editing flows inside it changes
  nothing the user sees now and is only confusing later. `null` keeps
  whatever restore points the importing browser already has.
- **`exported`, `version` and `app`** are ignored on import; update
  `exported` if you like.

What the app recomputes or replaces on import:

| What | Workspace file | Single flow file |
|---|---|---|
| The active flow's `viewport` | Reframed to fit the flow | Reframed to fit the flow |
| Other flows' `viewport` | Kept | (not applicable) |
| The flow's own `id` | Kept | Replaced with a new random id |
| All other ids | Kept | Kept |
| Unknown keys, anywhere | Dropped | Dropped |
| A link URL with an unsafe scheme | Blanked | Blanked |

Nothing in the file is a hash, checksum or counter, so there is nothing to
keep in sync by hand.

---

## 8. Things that go wrong

Each import outcome below was checked against the running app. Most of
these import **with a success message**, so the message alone does not prove
the file was right.

| Mistake | What happens |
|---|---|
| Invalid JSON (a trailing comma, a missing bracket) | Refused: *"Couldn't import: not a PumaFlow JSON or Mermaid file"*. |
| Workspace file without `"format": "pumaflow-workspace"` | Refused: *"Couldn't import: not a PumaFlow JSON or Mermaid file"*. |
| Workspace file with `"tabs": []` | Refused: *"That workspace file has no flows"*. |
| A flow wrapped in another envelope, e.g. `{ "data": { "nodes": … } }` | Refused: *"Couldn't import: not a PumaFlow JSON or Mermaid file"*. |
| Single flow with `nodes` not an array (`null`, an object) | Refused, same message. |
| Numeric ids, e.g. `"id": 1` and `"from": 1` | Imports with a success message, but every node gets a new random id and **every edge is dropped**. Lanes with numeric ids are replaced the same way and their nodes become unassigned. |
| An edge whose `from` or `to` matches no node | That edge is silently dropped. |
| `null` as an entry of `tabs` | The import fails part-way through with **no message at all**, and nothing is imported. |
| `null` as an entry of `nodes` | A new default "Process step" node appears at (0, 0). |
| `null` as an entry of `edges`, `lanes`, `annotations`, `groups` or `links` | Silently skipped. |
| Misspelled `type`, e.g. `"Decision"` or `"manualinput"` | The node becomes a plain `"process"` box. |
| Misspelled enum on an edge or lane (`style`, `arrows`, `routing`, anchors) | The default is used. |
| A hex color, e.g. `"#ff8800"`, or an unlisted name like `"orange"` | Becomes `null` (no tint). |
| A number written as a string, e.g. `"x": "-200"` | Becomes `0`; the node jumps to the origin. |
| Node `w` above 900 or `h` above 700 | Clamped to the limit. |
| A node's `lane` naming a lane that does not exist | The node is unassigned. |
| A node placed outside its lane's band | It keeps its `lane` but is drawn outside it. Dragging it later reassigns it to the lane its center lands in. |
| A link URL starting `javascript:` or another unlisted scheme | The URL is blanked; the label is kept. |
| `snap` written as `true` / `false` | Snap is left off either way; write `"1"` or `"0"`. |
| Any unknown key | Dropped. |

---

## 9. `null` handling in one place

`null` is accepted for any single field: it is treated like a missing value
and gets the default from §4. The only two places where `null` does harm are
array **entries**:

- `null` inside `tabs` stops the import with no message;
- `null` inside `nodes` creates an unwanted "Process step" node.

---

## 10. Checklist before handing a file over

A file that passes all of these imports with no warnings and nothing
renumbered or dropped.

**Shape**
- [ ] Workspace: `"format": "pumaflow-workspace"` and a non-empty `tabs`
      array. Single flow: `"format": "pumaflow"` and a `nodes` array.
- [ ] Workspace: `activeTabId` is one of the flows' ids.
- [ ] Every record has every field from §4.

**Ids and references**
- [ ] Every id is a **string**, unique within its array.
- [ ] Every edge's `from` and `to` is a node id in the same flow.
- [ ] Every node's `lane` is a lane id in the same flow, or `null`.
- [ ] No `null` entries inside any array.

**Values**
- [ ] Every `type`, `style`, `arrows`, `routing`, anchor, `loopSide` and
      `laneOrient` is one of the exact values in §4 (watch `manualInput`).
- [ ] Every color is one of the seven names, or `null`.
- [ ] Positions and sizes are numbers, not strings; sizes are within the
      limits in §4.
- [ ] Link URLs use `http:`, `https:`, `mailto:`, `tel:` or `file:`.

**Layout**
- [ ] Every node sits inside its lane's band (§6.3).
- [ ] Boxes do not overlap, and every decision's outgoing edges are labeled.
- [ ] Groups enclose the nodes they are meant to frame.

---

## 11. A complete example

A small access-request process in the workspace shape: two swimlanes, six
nodes using five different shapes, a decision with labeled Yes and No
branches, a self-loop, a curved connector with a waypoint, node metadata with
RACI and a document link, one annotation and one group. It imports with no
warnings, asks the user to confirm replacing the workspace, and then says
*"Workspace restored — 1 flow"*. Every value below is stored exactly as
written, except the active flow's `viewport`, which the app reframes to fit.

```json
{
  "format": "pumaflow-workspace",
  "version": 1,
  "app": "PumaFlow",
  "exported": "2026-10-28T09:00:00.000Z",
  "theme": "dark",
  "activeTabId": "tab_access",
  "tabs": [
    {
      "id": "tab_access",
      "title": "Access request",
      "nodes": [
        { "id": "n_start", "type": "terminator", "text": "Request submitted",
          "x": -185, "y": 0, "w": 130, "h": 56, "color": "blue", "note": "",
          "lane": "lane_req",
          "meta": { "owner": "", "system": "", "duration": "", "inputs": "", "outputs": "",
                    "raci": { "r": "", "a": "", "c": "", "i": "" }, "links": [] } },
        { "id": "n_form", "type": "manualInput", "text": "Fill in the access form",
          "x": -200, "y": 110, "w": 160, "h": 72, "color": null,
          "note": "Ask for the business reason, not just the system name.",
          "lane": "lane_req",
          "meta": { "owner": "Requester", "system": "Service portal", "duration": "5 min",
                    "inputs": "Business reason", "outputs": "Access ticket",
                    "raci": { "r": "Requester", "a": "Line manager", "c": "", "i": "Service desk" },
                    "links": [ { "label": "Access policy", "url": "https://intranet.example.com/policies/access" } ] } },
        { "id": "n_approve", "type": "decision", "text": "Manager approves?",
          "x": 45, "y": 100, "w": 150, "h": 100, "color": "amber", "note": "",
          "lane": "lane_desk",
          "meta": { "owner": "Line manager", "system": "Service portal", "duration": "1 day",
                    "inputs": "Access ticket", "outputs": "Decision",
                    "raci": { "r": "Line manager", "a": "Line manager", "c": "", "i": "Requester" },
                    "links": [] } },
        { "id": "n_grant", "type": "process", "text": "Grant access in the directory",
          "x": 40, "y": 260, "w": 160, "h": 70, "color": "green", "note": "",
          "lane": "lane_desk",
          "meta": { "owner": "Service desk", "system": "Directory", "duration": "2 hours",
                    "inputs": "Approved ticket", "outputs": "Group membership",
                    "raci": { "r": "Service desk", "a": "IT manager", "c": "", "i": "Requester" },
                    "links": [] } },
        { "id": "n_reject", "type": "document", "text": "Rejection notice",
          "x": -200, "y": 260, "w": 160, "h": 78, "color": "red", "note": "",
          "lane": "lane_req",
          "meta": { "owner": "", "system": "", "duration": "", "inputs": "", "outputs": "",
                    "raci": { "r": "", "a": "", "c": "", "i": "" }, "links": [] } },
        { "id": "n_end", "type": "terminator", "text": "Ticket closed",
          "x": 55, "y": 400, "w": 130, "h": 56, "color": "gray", "note": "",
          "lane": "lane_desk",
          "meta": { "owner": "", "system": "", "duration": "", "inputs": "", "outputs": "",
                    "raci": { "r": "", "a": "", "c": "", "i": "" }, "links": [] } }
      ],
      "edges": [
        { "id": "e1", "from": "n_start", "to": "n_form", "fromAnchor": "auto", "toAnchor": "auto",
          "label": "", "style": "solid", "color": null, "arrows": "end", "routing": "ortho",
          "loopSide": "right", "waypoint": null, "fromOrder": null, "toOrder": null },
        { "id": "e2", "from": "n_form", "to": "n_approve", "fromAnchor": "auto", "toAnchor": "auto",
          "label": "", "style": "solid", "color": null, "arrows": "end", "routing": "ortho",
          "loopSide": "right", "waypoint": null, "fromOrder": null, "toOrder": null },
        { "id": "e3", "from": "n_approve", "to": "n_grant", "fromAnchor": "bottom", "toAnchor": "top",
          "label": "Yes", "style": "thick", "color": "green", "arrows": "end", "routing": "ortho",
          "loopSide": "right", "waypoint": null, "fromOrder": null, "toOrder": null },
        { "id": "e4", "from": "n_approve", "to": "n_reject", "fromAnchor": "left", "toAnchor": "top",
          "label": "No", "style": "dashed", "color": "red", "arrows": "end", "routing": "ortho",
          "loopSide": "right", "waypoint": null, "fromOrder": null, "toOrder": null },
        { "id": "e5", "from": "n_approve", "to": "n_approve", "fromAnchor": "auto", "toAnchor": "auto",
          "label": "Needs more info", "style": "dashdot", "color": null, "arrows": "end", "routing": "ortho",
          "loopSide": "right", "waypoint": null, "fromOrder": null, "toOrder": null },
        { "id": "e6", "from": "n_grant", "to": "n_end", "fromAnchor": "auto", "toAnchor": "auto",
          "label": "", "style": "solid", "color": null, "arrows": "end", "routing": "ortho",
          "loopSide": "right", "waypoint": null, "fromOrder": null, "toOrder": null },
        { "id": "e7", "from": "n_reject", "to": "n_end", "fromAnchor": "auto", "toAnchor": "left",
          "label": "", "style": "dotted", "color": null, "arrows": "end", "routing": "curved",
          "loopSide": "right", "waypoint": { "x": -120, "y": 430 }, "fromOrder": null, "toOrder": null }
      ],
      "lanes": [
        { "id": "lane_req", "title": "Requester", "color": "blue", "width": 240 },
        { "id": "lane_desk", "title": "Service desk", "color": null, "width": 240 }
      ],
      "laneOrigin": -240,
      "laneOrient": "v",
      "annotations": [
        { "id": "ann_sla", "x": 260, "y": 100, "w": 200, "h": 90,
          "text": "Approvals older than three days are escalated to the department head.",
          "color": "amber" }
      ],
      "groups": [
        { "id": "grp_fulfill", "x": 20, "y": 235, "w": 200, "h": 235,
          "text": "IT fulfillment", "color": "teal" }
      ],
      "viewport": { "x": 0, "y": 0, "zoom": 1 }
    }
  ],
  "exportTheme": "light",
  "grid": "dots",
  "snap": "0",
  "accent": null,
  "versions": null
}
```

What the app makes of this, as a check on your own reasoning. These were
read back from the app after importing this exact file:

- Lane "Requester" covers x from -240 to 0 and "Service desk" from 0 to 240.
  Every node's center falls inside the lane its `lane` field names.
- The group "IT fulfillment" frames the grant step and the closing step; the
  annotation sits to the right of the lanes.
- The Markdown spec numbers the steps 1 Request submitted, 2 Fill in the
  access form, 3 Manager approves?, 4 Rejection notice, 5 Grant access in the
  directory, 6 Ticket closed, and groups them as Requester (1, 2, 4) and
  Service desk (3, 5, 6).
- Exporting the workspace again gives back this file exactly, apart from
  `exported` and the reframed `viewport`.

To deliver the same flow as a single flow file instead, take the one object
inside `tabs`, drop its `id`, and add `"format": "pumaflow"` and
`"version": 1` at the top (§2.2).
