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

This document describes PumaMapper's `.pumapack` files in enough detail to
**edit an exported map, or generate one from scratch**, so that it imports
cleanly: no warnings, no re-numbered ids, and nothing silently dropped. It is
written for a reader, human or AI, who has no access to the app's source.

A `.pumapack` is a UTF-8 JSON file holding **one mind map**. You get one from
a map's tab: right-click the tab and choose **Export as .pumapack**.
PumaMapper imports it in any of three ways:

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

Importing a `.pumapack` always **adds** a new map tab. It never replaces or
merges into a map you already have.

PumaMapper also writes a second JSON shape: the **full backup** that the
topbar **Export** button saves as `pumamapper-backup-<date>.json` (`Cmd+S` /
`Ctrl+S` saves the same content). It holds every map at once, and importing
it **replaces** all of your maps. §7 documents it. Both shapes share the same
node record (§4), so everything about nodes applies to both.

If you are generating a map for someone, generate a `.pumapack`. It cannot
overwrite anything.

---

## 1. The short version

If you only read one section, read this one.

1. Use the envelope from §2 exactly. `puma.app` must be `"pumamapper"` and
   `puma.format` must be the **number** `1`.
2. The map is `data.tree`: **one root node** whose children are the branches.
   The root must have a `children` array, even an empty one.
3. Write **all nine fields** on every node (§4): `id`, `text`, `collapsed`,
   `pos`, `color`, `icon`, `note`, `links`, `children`.
4. Every `id` is a string, **unique within the map**. Cross-links (`links`)
   point at these ids.
5. `color` and `icon` are one of the exact lowercase ids in §4, or `null`.
   Anything else is silently turned into `null`.
6. **Never put `null` inside a `children` array.** Use `[]` for a node with no
   children.
7. Array order is display order. Leave `pos` as `null` and `viewport` as
   `{ "x": 0, "y": 0, "zoom": 1 }` unless you have a reason not to.
8. `data.nodes` and `data.edges` are a flattened copy of the tree for other
   apps. PumaMapper ignores them on import and rebuilds them on export. Keep
   them consistent with the tree (§5), or write `[]` for both.
9. Save the file with a `.pumapack` (or `.json`) extension.
10. Check the result against §10.

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

---

## 2. The envelope

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumamapper",
    "appVersion": "generated",
    "format": 1,
    "exportedAt": "2026-10-28T09:00:00.000Z",
    "title": "Phishing incident review"
  },
  "data": { "...the map, see §3..." }
}
```

| Key | Value | Notes |
|---|---|---|
| `$schema` | `"https://pumaworx.dev/pumapack/v1"` | Not read on import, but write it. |
| `puma.app` | `"pumamapper"` | **Required.** It is how the file is recognized as a pack at all, and it decides how the map is read. See below. |
| `puma.format` | `1` | **Required, and must be the number `1`.** `"1"` (a string) is refused. This is the only format there is. |
| `puma.appVersion` | any string | Free text. The app writes `"dev"` or a build id. |
| `puma.exportedAt` | ISO 8601 datetime | Informational. |
| `puma.title` | string | Used as the map's name only if `data.title` is empty. |
| `data` | object | The map. See §3. |

What the importer actually requires, in the order it checks:

- **The file is valid JSON and has a `puma` object with a non-empty
  `puma.app`.** If not, the file is not recognized as a pack at all. The app
  then tries to read it as a Markdown outline, finds none, and says:
  *"Couldn't import: no bullet list found"*. That message is what you see for
  invalid JSON, a missing `puma` block, or a bare map with no envelope.
- **`puma.format` is exactly `1`.** If not: *"Couldn't import pumapack:
  Unsupported .pumapack format: 1"* (the message shows whatever value was
  found, including `undefined`).
- **`data.tree` exists and `data.tree.children` is an array.** If not:
  *"Couldn't import pumapack: Pumapack data missing tree"*.

`puma.app` matters beyond recognition:

- `"pumamapper"`: the map is read from `data.tree`, and nothing is lost.
- Any other value, with `data.nodes` and `data.edges` arrays present: the
  file is treated as another app's pack. `data.tree` is **ignored** and the
  map is rebuilt from the flat nodes and edges, which **loses every cross-link,
  every collapsed state, every `pos` and the `layout`**. There is no warning.
- Any other value without those arrays: refused, for example *"Couldn't import
  pumapack: That's a PumaPlanner pumapack — PumaMapper doesn't know how to
  read it. Open it in PumaPlanner instead."*

On success the app says *"Imported .pumapack — 13 nodes"*, counting every
node in the tree, and the new map becomes the active tab.

---

## 3. The map object (`data`)

```json
{
  "title": "Phishing incident review",
  "viewport": { "x": 0, "y": 0, "zoom": 1 },
  "layout": "radial",
  "tree":  { "...the root node, see §4..." },
  "nodes": [ ],
  "edges": [ ]
}
```

| Field | Type | Notes |
|---|---|---|
| `title` | string | The map's name, shown on its tab. Surrounding spaces are trimmed. If it is empty or not a string, `puma.title` is used, and failing that the map is named `"Imported"`. |
| `viewport` | `{ x, y, zoom }` | Where the view starts. See §6. Write `{ "x": 0, "y": 0, "zoom": 1 }`. |
| `layout` | `"radial"` \| `"down"` \| `"right"` \| `"left"` | How the map is drawn. See §6. Anything else becomes `"radial"`. |
| `tree` | node | The root node. The whole map hangs off it. See §4. |
| `nodes` | array | Flattened copy of the tree, for other apps. **Ignored by PumaMapper on import.** See §5. |
| `edges` | array | Parent-to-child pairs, for other apps. **Ignored by PumaMapper on import.** See §5. |

Any other key in `data` is ignored.

The map's **title** and the **root node's text** are separate. The title
names the tab; the root's `text` is the central idea drawn in the middle of
the map. They are often the same, but need not be.

---

## 4. The node record

Every node in the tree, the root included, has this shape:

```json
{
  "id": "n_mfa",
  "text": "MFA not enforced on legacy mail",
  "collapsed": false,
  "pos": null,
  "color": "amber",
  "icon": "lock",
  "note": "",
  "links": ["n_creds"],
  "children": []
}
```

| Field | Type | Meaning | If missing or invalid |
|---|---|---|---|
| `id` | string | The node's identity, unique within the map. Any string works: the app generates ids like `node_k2j9x0a1b3c4`, and short readable ids (`n_root`, `n_mfa`) work just as well. | A fresh random id is generated, and any `links` to the old value no longer resolve. |
| `text` | string | The node's label. `\n` makes a line break inside the node. `""` is allowed and shows as an empty node. | `""` |
| `collapsed` | boolean | `true` hides this node's children until the user expands it. Its links to hidden nodes are hidden too. | `false` (any truthy value counts as `true`) |
| `pos` | `null` or `{ "x": number, "y": number }` | A position the user dragged the node to. See §6. Write `null`. | `null`, including when either coordinate is not a number |
| `color` | one of the color ids below, or `null` | A colored tag. | `null`, silently |
| `icon` | one of the icon ids below, or `null` | An icon drawn before the text. | `null`, silently |
| `note` | string | A long-form plain-text note, shown in the notes panel. `\n` for new lines. No Markdown rendering. | `""` |
| `links` | array of node ids | Cross-links from this node to other nodes in the same map. Drawn as dashed arrows pointing **at** the target. See §5. | `[]`. Non-string entries are removed; duplicates are removed. A string instead of an array becomes `[]`. |
| `children` | array of nodes | This node's children, **in display order**. | `[]` |

**Any other key on a node is silently dropped.** There is nowhere to store
extra data on a node except `note`.

### Colors

| id | Shown as |
|---|---|
| `"red"` | red |
| `"amber"` | amber |
| `"green"` | green |
| `"blue"` | blue |
| `"violet"` | violet |
| `"teal"` | teal |
| `"gray"` | gray |

Exact lowercase ids only: `"Green"`, `"orange"` and `"#ff0000"` all become
`null`.

### Icons

| id | Glyph | Typical use |
|---|---|---|
| `"star"` | ★ | highlight |
| `"warn"` | ⚠ | risk, warning |
| `"ok"` | ✓ | done |
| `"q"` | ? | open question |
| `"bolt"` | ⚡ | priority |
| `"bulb"` | ◉ | idea |
| `"lock"` | ⊘ | blocked, restricted |

Write the **id**, never the glyph: `"icon": "warn"`, not `"icon": "⚠"`.
Anything else becomes `null`.

---

## 5. Cross-references

There are only two kinds of reference, and both stay within one map:

| From | Field | To |
|---|---|---|
| node | `links[]` | another node's `id` in the same tree |
| `data.edges[]` | `from`, `to` | `id`s in `data.nodes` (and so in the tree) |

**Links.**

- A link lives on the node it starts from. `"links": ["n_creds"]` on `n_mfa`
  draws an arrow from `n_mfa` to `n_creds`.
- A link can point anywhere in the same map: a sibling, a cousin, an
  ancestor or a descendant. Links are in addition to the tree structure; they
  never make a node a child of anything.
- A link to an id that does not exist is **kept but never drawn**. So is a
  link from a node to itself.
- A link is drawn only while both ends are visible. A collapsed parent hides
  the links of the nodes inside it.

**`data.nodes` and `data.edges`.** These are a flattened copy of the tree,
there so that apps that work on flat graphs can read a PumaMapper pack.
PumaMapper ignores both on import (when `puma.app` is `"pumamapper"`) and
regenerates both on every export, so they can never override the tree. To
write them consistently:

- Walk the tree depth-first, **a node before its children**, children in
  array order. Each node visited adds one entry to `nodes`:
  `{ "id", "label", "color", "icon", "note" }`, where `label` is the node's
  `text` and the other three are copied as they are.
- Each child visited adds one entry to `edges`: `{ "from": <parent id>,
  "to": <child id> }`, in the same order.
- The root has no edge. A tree of *n* nodes has *n* entries in `nodes` and
  *n* − 1 in `edges`.
- `links`, `collapsed` and `pos` have no place in the flat copy.

If you only care about PumaMapper, `"nodes": []` and `"edges": []` import
exactly the same.

---

## 6. Order, layout and position

- **Order.** A node's `children` array is its children's order on the map and
  in the outline. There is no separate order or index field.
- **Layout** is chosen per map:

  | `layout` | Drawn as |
  |---|---|
  | `"radial"` | Root in the middle. The root's children alternate right and left: the 1st, 3rd, 5th… go right, the 2nd, 4th, 6th… go left, and each branch keeps its side. |
  | `"down"` | Root at the top, everything cascading downward, like an org chart. |
  | `"right"` | Root at the left, everything cascading to the right. |
  | `"left"` | Root at the right, everything cascading to the left. |

  Positions are computed from the tree every time the map is drawn. They are
  not stored in the file.
- **`pos`** overrides the computed position of one node. It is set when the
  user drags a node somewhere by hand. The coordinates are in map units, with
  the root's computed position at `(0, 0)`, and the node's whole subtree moves
  with it. In a generated map, leave every `pos` as `null` and let the layout
  do the work; a guessed `pos` usually lands a node on top of another.
- **`viewport`** is the view the map opens at. `x` and `y` are a pan offset
  in screen pixels of the root from the center of the map pane, and `zoom` is
  a scale factor. On import, `x` and `y` that are not numbers become `0`, and
  `zoom` is clamped to 0.25–3 (a missing or zero zoom becomes 1). The view is
  **not** re-fitted on import; the user presses `F` to fit the map to the
  window. `{ "x": 0, "y": 0, "zoom": 1 }` centers the root at normal size.
- **Size.** There is no limit on depth or on the number of children, but a
  map is easiest to read with a handful of branches off the root and short
  node text. Put detail in `note`.

---

## 7. The full backup (`pumamapper-env`)

This is what the topbar **Export** button writes: every map, plus two display
preferences, in one file. **Importing it replaces every map the user has.**

```json
{
  "format": "pumamapper-env",
  "version": 1,
  "exportedAt": "2026-10-28T09:00:00.000Z",
  "activeTabId": "tab_review",
  "prefs": { "outlineW": null, "accent": null },
  "tabs": [
    {
      "id": "tab_review",
      "title": "Phishing incident review",
      "root": { "...a node, see §4..." },
      "viewport": { "x": 0, "y": 0, "zoom": 1 },
      "layout": "down",
      "accent_color": "#e05050",
      "updatedAt": "2026-10-28T09:00:00.000Z"
    }
  ]
}
```

| Key | Value | Notes |
|---|---|---|
| `format` | `"pumamapper-env"` | **Required.** This one key is what marks the file as a backup; it is checked before anything else. |
| `version` | `1` | Not checked. |
| `exportedAt` | ISO 8601 datetime | Informational. |
| `activeTabId` | a tab `id` | The map that opens first. If it matches no tab, the first tab opens. |
| `prefs.outlineW` | number or `null` | Width of the outline pane in pixels. `null` leaves the current width. |
| `prefs.accent` | `"#rrggbb"` or `null` | The app's accent color. `null` leaves the current one. |
| `tabs` | array of maps | **Must be non-empty.** If it is empty or missing: *"Backup has no maps"*. |

Each entry in `tabs`:

| Field | Type | Notes |
|---|---|---|
| `id` | string | The map's identity, unique within the file. Kept as written; a non-string gets a fresh id. |
| `title` | string | The map's name. A non-string becomes `"Untitled"`. |
| `root` | node | The root node, exactly as `data.tree` in a pack (§4). The same node rules and defaults apply. |
| `viewport` | `{ x, y, zoom }` | Used only if `x` is a number; otherwise `{ 0, 0, 1 }`. Unlike a pack, `y` and `zoom` are not checked or clamped here, so write real numbers. |
| `layout` | as in §6 | Anything else becomes `"radial"`. |
| `accent_color` | `"#rrggbb"` or `null` | The colored dot on the map's tab. `null` uses the app's accent. |
| `updatedAt` | ISO 8601 datetime or `null` | When the map's content last changed. |

Any other key on a tab is dropped. The backup does not carry the theme or the
map/outline/split view.

What the user sees:

- If they already have more than one map, or their one map has anything under
  its root, the browser asks first: *"This file is a full PumaMapper
  environment backup containing 2 maps."* followed by *"Importing it will
  REPLACE your current 1 map. Unsaved changes will be lost."* and
  *"Continue?"*. Cancel leaves everything as it was, with no message. On an
  empty workspace there is no question.
- On success: *"Restored 2 maps from backup"*.
- The restored **active** map's `updatedAt` is re-stamped with the time of
  the import. Every other map keeps the value from the file.

To hand an AI a single map, prefer the map's `.pumapack` over the full backup.
To hand it everything, the backup is the only shape that carries several maps.

---

## 8. Editing an existing export

**Keep as they are:**

- Every node `id`. Links point at ids, so changing one breaks every link to
  it. If you must change an id, change every `links` entry that names it in
  the same edit.
- `puma.app` and `puma.format`. Change nothing in the `puma` block except,
  optionally, `title`.
- In a backup: every tab `id` and `activeTabId`.

**Safe to change:** `text`, `note`, `color`, `icon`, `collapsed`, `links`,
`data.title`, `layout`, and the shape of the tree itself.

**Common edits:**

- *Add a node:* insert a complete node object (all nine fields) into its
  parent's `children` at the position it should appear. Give it a new id that
  is not used anywhere else in the map.
- *Move a node:* cut the whole object, children included, and paste it into
  its new parent's `children`. Set its `pos` to `null` so it flows into the
  layout at its new place (the app does the same when a user moves a node).
- *Delete a node:* remove the object, which deletes its whole subtree. Then
  remove its id, and the ids of everything under it, from any other node's
  `links`. A leftover link is harmless but stays in the file.
- *Rename the map:* change `data.title` (and `puma.title`, to keep them in
  step).

**What the app regenerates, so you need not maintain it:**

- `data.nodes` and `data.edges`, on every export.
- `puma.exportedAt` and `puma.appVersion`, on every export.
- The map's internal tab identity. A `.pumapack` carries none; every import
  creates a new tab, so importing the same file twice gives two separate
  copies of the map.
- The map's last-changed time, set to the moment of import.

**Never hand-edit:** node ids that other nodes link to, unless you update the
links too.

---

## 9. Things that go wrong

Every row below was checked by importing a file with that mistake.

| Mistake | What happens |
|---|---|
| A bare node or `data` object with no envelope | Not recognized. *"Couldn't import: no bullet list found"*. |
| No `puma.app` | Same: *"Couldn't import: no bullet list found"*. |
| Invalid JSON (a trailing comma, a comment) | Same: *"Couldn't import: no bullet list found"*. |
| `"format": "1"`, or no `format` | Refused: *"Couldn't import pumapack: Unsupported .pumapack format: 1"* (or `undefined`). |
| No `data.tree`, or a root without a `children` array | Refused: *"Couldn't import pumapack: Pumapack data missing tree"*. |
| `puma.app` set to another app's name, with `nodes` and `edges` present | Imported from `nodes`/`edges` instead of the tree. All links, collapsed states, positions and the layout are lost, with no warning. |
| `puma.app` set to another app's name, without `nodes`/`edges` | Refused: *"Couldn't import pumapack: That's a PumaPlanner pumapack — …"* |
| `null` inside a `children` array | The map **is** added, with a blank node in place of the `null`, but the app reports *"Couldn't import pumapack: Cannot read properties of null (reading 'id')"*. Retrying adds a second copy. |
| `children` missing or `null` on a non-root node | Treated as no children. |
| Two nodes with the same `id` | Kept as written, and not detected. The app finds nodes by id, so selecting, editing or linking one of the pair can act on the other. |
| `id` not a string (e.g. `7`) | Replaced with a fresh random id; links to it no longer resolve. |
| `text` not a string | Becomes `""`. |
| `"color": "orange"`, `"Green"`, or an icon glyph such as `"⚠"` | Becomes `null`, silently. |
| `links` as a string (`"n_mfa"`) | Becomes `[]`; the link is lost. |
| A link to a missing id | Kept in the file, never drawn. |
| `pos` with a non-number coordinate | Becomes `null`. |
| An unknown key on a node (e.g. `"owner"`) | Dropped. Use `note` for extra detail. |
| `layout` misspelled | Becomes `"radial"`. |
| `viewport.zoom` of 10 | Clamped to 3. |
| Edited `data.nodes` but not `data.tree` | The edits are ignored; the tree is what imports. |
| `data.title` and `puma.title` both empty | The map is named `"Imported"`. |
| File saved as `.md`, `.opml` or `.xml` | Read as a Markdown or OPML outline instead of a pack. |

---

## 10. Checklist before handing a pack over

A pack that passes all of these imports with no warnings and nothing dropped.

**Envelope**
- [ ] The file is valid JSON (no comments, no trailing commas), saved as
      `.pumapack` or `.json`.
- [ ] `puma.app` is `"pumamapper"` and `puma.format` is the number `1`.
- [ ] `data.title` is a non-empty string.
- [ ] `data.tree` is one node, and it has a `children` array.

**Nodes**
- [ ] Every node has all nine fields from §4, with the right types.
- [ ] No `null` anywhere inside a `children` array.
- [ ] Every `id` is a string, and no two nodes share one.
- [ ] Every `color` and `icon` is an exact id from §4, or `null`.
- [ ] Every `pos` is `null` (unless you deliberately placed a node).

**References**
- [ ] Every entry in every `links` array is the id of another node in the
      same tree.
- [ ] `data.nodes` and `data.edges` either match the tree as described in §5,
      or are both `[]`.

**View**
- [ ] `layout` is `"radial"`, `"down"`, `"right"` or `"left"`.
- [ ] `viewport` is `{ "x": 0, "y": 0, "zoom": 1 }`, or real numbers with a
      zoom between 0.25 and 3.

---

## 11. A complete example

A small post-incident review map: a root with a note, four branches with
colors and icons, a line break inside one node, two cross-links, and one
collapsed branch. `data.nodes` and `data.edges` are filled in as §5
describes. It imports with no warnings, and the app reports
*"Imported .pumapack — 13 nodes"*. Exporting the imported map as a
`.pumapack` gives back this same file, apart from `puma.appVersion` and
`puma.exportedAt`.

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumamapper",
    "appVersion": "generated",
    "format": 1,
    "exportedAt": "2026-10-28T09:00:00.000Z",
    "title": "Phishing incident review"
  },
  "data": {
    "title": "Phishing incident review",
    "viewport": { "x": 0, "y": 0, "zoom": 1 },
    "layout": "radial",
    "tree": {
      "id": "n_root", "text": "October phishing incident", "collapsed": false, "pos": null,
      "color": null, "icon": null,
      "note": "Post-incident review, held 27 Oct.\nScope: the credential-phishing wave of 14-16 Oct.",
      "links": [],
      "children": [
        {
          "id": "n_what", "text": "What happened", "collapsed": false, "pos": null,
          "color": "blue", "icon": null, "note": "", "links": [],
          "children": [
            { "id": "n_lure", "text": "Fake invoice lure\nsent to 212 staff", "collapsed": false, "pos": null,
              "color": null, "icon": null,
              "note": "Sender spoofed a real supplier. The link led to a cloned sign-in page.",
              "links": [], "children": [] },
            { "id": "n_creds", "text": "9 accounts entered passwords", "collapsed": false, "pos": null,
              "color": "red", "icon": "warn", "note": "", "links": [], "children": [] }
          ]
        },
        {
          "id": "n_well", "text": "What went well", "collapsed": false, "pos": null,
          "color": "green", "icon": "ok", "note": "", "links": [],
          "children": [
            { "id": "n_report", "text": "First report within 11 minutes", "collapsed": false, "pos": null,
              "color": null, "icon": "star", "note": "", "links": [], "children": [] }
          ]
        },
        {
          "id": "n_gaps", "text": "Gaps", "collapsed": false, "pos": null,
          "color": "amber", "icon": null, "note": "", "links": [],
          "children": [
            { "id": "n_mfa", "text": "MFA not enforced on legacy mail", "collapsed": false, "pos": null,
              "color": "amber", "icon": "lock", "note": "", "links": ["n_creds"], "children": [] },
            { "id": "n_why", "text": "Why did the filter miss it?", "collapsed": false, "pos": null,
              "color": null, "icon": "q", "note": "", "links": [], "children": [] }
          ]
        },
        {
          "id": "n_actions", "text": "Actions", "collapsed": true, "pos": null,
          "color": "violet", "icon": "bolt", "note": "", "links": [],
          "children": [
            { "id": "n_act1", "text": "Enforce MFA on all mailboxes", "collapsed": false, "pos": null,
              "color": null, "icon": null, "note": "Owner: Tom Reyes. Due 30 Nov.",
              "links": ["n_mfa"], "children": [] },
            { "id": "n_act2", "text": "Run a reporting drill", "collapsed": false, "pos": null,
              "color": "teal", "icon": "bulb", "note": "", "links": [], "children": [] },
            { "id": "n_act3", "text": "Retire the old mail gateway", "collapsed": false, "pos": null,
              "color": "gray", "icon": null, "note": "", "links": [], "children": [] }
          ]
        }
      ]
    },
    "nodes": [
      { "id": "n_root", "label": "October phishing incident", "color": null, "icon": null,
        "note": "Post-incident review, held 27 Oct.\nScope: the credential-phishing wave of 14-16 Oct." },
      { "id": "n_what", "label": "What happened", "color": "blue", "icon": null, "note": "" },
      { "id": "n_lure", "label": "Fake invoice lure\nsent to 212 staff", "color": null, "icon": null,
        "note": "Sender spoofed a real supplier. The link led to a cloned sign-in page." },
      { "id": "n_creds", "label": "9 accounts entered passwords", "color": "red", "icon": "warn", "note": "" },
      { "id": "n_well", "label": "What went well", "color": "green", "icon": "ok", "note": "" },
      { "id": "n_report", "label": "First report within 11 minutes", "color": null, "icon": "star", "note": "" },
      { "id": "n_gaps", "label": "Gaps", "color": "amber", "icon": null, "note": "" },
      { "id": "n_mfa", "label": "MFA not enforced on legacy mail", "color": "amber", "icon": "lock", "note": "" },
      { "id": "n_why", "label": "Why did the filter miss it?", "color": null, "icon": "q", "note": "" },
      { "id": "n_actions", "label": "Actions", "color": "violet", "icon": "bolt", "note": "" },
      { "id": "n_act1", "label": "Enforce MFA on all mailboxes", "color": null, "icon": null,
        "note": "Owner: Tom Reyes. Due 30 Nov." },
      { "id": "n_act2", "label": "Run a reporting drill", "color": "teal", "icon": "bulb", "note": "" },
      { "id": "n_act3", "label": "Retire the old mail gateway", "color": "gray", "icon": null, "note": "" }
    ],
    "edges": [
      { "from": "n_root", "to": "n_what" },
      { "from": "n_what", "to": "n_lure" },
      { "from": "n_what", "to": "n_creds" },
      { "from": "n_root", "to": "n_well" },
      { "from": "n_well", "to": "n_report" },
      { "from": "n_root", "to": "n_gaps" },
      { "from": "n_gaps", "to": "n_mfa" },
      { "from": "n_gaps", "to": "n_why" },
      { "from": "n_root", "to": "n_actions" },
      { "from": "n_actions", "to": "n_act1" },
      { "from": "n_actions", "to": "n_act2" },
      { "from": "n_actions", "to": "n_act3" }
    ]
  }
}
```

What the app shows for this map, as a check on your own reasoning:

- The root, *October phishing incident*, sits in the middle with its note in
  the notes panel.
- In the radial layout, *What happened* and *Gaps* (the 1st and 3rd branches)
  go right; *What went well* and *Actions* (the 2nd and 4th) go left.
- *Fake invoice lure* is drawn on two lines.
- A dashed arrow runs from *MFA not enforced on legacy mail* to *9 accounts
  entered passwords*.
- *Actions* is collapsed, so its three children, and the link from *Enforce
  MFA on all mailboxes* back to the MFA gap, appear only once the user
  expands it.

---

## 12. Other files Import accepts

For completeness, the same Import button also reads:

- **The full backup** described in §7.
- **An older single-map file**: a JSON object with a `root` node (whose
  `children` is an array) and optional `title`, `viewport` and `layout`, with
  no `puma` envelope. The app's bundled example maps use it. The same node
  rules apply, and the map is added as a new tab. The app no longer writes
  this shape; write a `.pumapack` instead.
- **Markdown** (`.md`) nested bullet lists and **OPML** (`.opml`, `.xml`)
  outlines.
- **Other PumaWorx apps' packs** that carry flat `data.nodes` and
  `data.edges`, such as PumaFlow's. They are folded into a tree, with one
  synthetic root if the graph has several starting points. Their format is
  documented by the app that writes them.
