# The `.pumapack` file format (PumaRisk, schema 2)

This document describes PumaRisk's `.pumapack` files in enough detail to
**edit an existing export** or **generate one from scratch** so that it imports
cleanly. The app opens the result with no warnings, no re-numbered refs and no
dropped fields. 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. PumaRisk imports it in either of two ways:

- from the topbar **Import** button;
- by dropping the file anywhere on the window.

**Import replaces everything.** Every workspace currently in the app is swapped
for the workspaces in the file. It never merges into, or adds alongside, what
is already there. See §2.3.

"Schema 2" is the envelope's `puma.format` number. PumaRisk has no separate
data-schema version.

---

## 1. The short version

If you only read one section, read this one.

1. Wrap your workspaces in the envelope from §2: `puma.app` must be
   `"pumarisk"`, and `data.workspaces` must be a **non-empty array**.
2. Import **replaces every workspace** in the app. To edit a user's data,
   start from a **full backup** (topbar **Export**), never from a
   single-workspace export, or the other workspaces are lost. See §2.4.
3. Write **every field** of every risk, using the shape in §4. Use `""` for
   "nothing". **Never write `null`** except for `residual` and the two
   `data.prefs` values.
4. Enum values are exact lower-case ids. An unknown `status` silently becomes
   `"open"`, and an unknown `responseStrategy` silently becomes `""`. See §4.
5. Scores are `{ "likelihood": n, "impact": n }` with **whole numbers 1 to 5**.
   `residual` is either `null` or has both numbers.
6. Give every risk a ref `RR-001`, `RR-002`, and so on, numbered **per
   workspace**. Set that workspace's `refSeq` to **at least the highest ref
   number used**. See §6.3.
7. Risk `id`s must be unique within their workspace, and workspace `slug`s must
   be unique across the file.
8. `raisedAt` is `YYYY-MM-DD`. `createdAt` and `updatedAt` are full ISO 8601
   datetimes.
9. Check the result against the checklist in §9.

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

---

## 2. The envelope

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumarisk",
    "appVersion": "generated",
    "format": 2,
    "exportedAt": "2026-09-28T09:00:00.000Z",
    "kind": "backup",
    "title": "PumaRisk backup"
  },
  "data": {
    "workspaces": [ { "...one workspace object, see §3..." } ],
    "activeSlug": "it-operations",
    "prefs": { "theme": null, "accent": null }
  }
}
```

| Key | Value | Notes |
|---|---|---|
| `$schema` | `"https://pumaworx.dev/pumapack/v1"` | Not checked. Write it; the app does. |
| `puma` | object | **Required.** Its presence is what marks the file as a pack. |
| `puma.app` | `"pumarisk"` | **Required, exact.** Any other value is refused (§2.2). |
| `puma.appVersion` | any string | The build that wrote the file. Free text on import; the app writes a short commit id, or `"dev"`. |
| `puma.format` | `2` | Not checked on import. Write `2`. |
| `puma.exportedAt` | ISO 8601 datetime | Informational. |
| `puma.kind` | `"backup"` or `"workspace-export"` | Informational. See §2.4. |
| `puma.title` | string | Informational. |
| `puma.note` | string | Written on workspace exports only, e.g. `"3 of 10 risks (current filter)."`. Informational. |
| `data` | object | **Required.** |
| `data.workspaces` | array of workspace objects | **Required, non-empty.** See §3. |
| `data.activeSlug` | a workspace `slug`, or `null` | Which tab is open after import. Missing, `null` or unknown means the first workspace. |
| `data.prefs.theme` | `"light"`, `"dark"` or `null` | `"light"` or `"dark"` switches the app's theme and saves it. Anything else leaves the user's theme alone. |
| `data.prefs.accent` | `"#rrggbb"` or `null` | A color switches the app's accent color and saves it. `null` or `""` leaves it alone. |

`data.prefs` is optional. For a generated pack, write
`{ "theme": null, "accent": null }` or leave it out, so the user's own theme
and accent are kept. Any other envelope key is ignored.

### 2.1 What the importer requires

The checks run in this order. The first failure shows its message as a toast,
and nothing in the app changes.

| Problem | Message |
|---|---|
| Not valid JSON | *"Import failed — not a JSON / .pumapack backup."* |
| No `puma` object | *"Not a .pumapack file — missing the puma envelope."* |
| No `data` object | *"Import failed — no data payload."* |
| `puma.app` is another app | *"That's a PumaTracker pumapack. PumaRisk doesn't know how to read it — open it in PumaTracker instead."* (with that app's name) |
| `puma.app` is missing | *"That's a an unknown app pumapack. PumaRisk doesn't know how to read it — open it in an unknown app instead."* |
| `data.workspaces` missing, not an array, or empty | *"Import failed — no workspaces found."* |
| A structure the importer cannot read (see §8) | *"Import failed — the backup file is malformed or corrupt."* |

A bare workspace object, or a bare `{ "workspaces": [...] }` with no `puma`
envelope, **is rejected**.

### 2.2 Other apps' packs

PumaRisk also reads PumaGRC2 assessment packs and PumaTTX exercise packs,
turning their findings into risks. Those follow the other app's own format, add
or merge risks instead of replacing workspaces, and are not covered here. Any
other app's pack is refused with the message above.

### 2.3 What the user sees on import

- If the app currently holds **no risks at all**, the import applies straight
  away.
- Otherwise an in-app dialog asks first:
  *"Import 2 workspaces (5 risks)?"* /
  *"This replaces every workspace currently loaded. Export a backup first if
  you want to keep them."*, with **Cancel** and **Replace and import**.
- On success the toast reads *"Imported 2 workspaces (5 risks)."* The counts
  are the file's; the words become singular for one (`1 workspace`, `1 risk`).

### 2.4 The pack shapes

All three are the same envelope and the same workspace shape. They differ in
what they contain:

| Shape | Made by | `puma.kind` | Contents |
|---|---|---|---|
| **Full backup** | Topbar **Export** (`.pumapack`), or `⌘S` / `Ctrl+S` (`.json`) | `"backup"` | Every workspace, every risk, plus `data.prefs`. **This is the one to hand to an AI.** |
| **Workspace export** | Right-click a workspace tab → **JSON (.pumapack)** | `"workspace-export"` | One workspace, and **only the risks matching its current filter**. No `data.prefs`. |
| **Older single-register file** | Early versions of the app | none | `data.register` instead of `data.workspaces`. Still imported, as one workspace. Do not generate it. |

Importing a workspace export **still replaces every workspace**, so a user who
exports one tab, edits it, and imports it back is left with that one tab.
Always work from a full backup when the result is going back into the same
app.

---

## 3. The workspace object

A workspace is one risk register, shown as one tab.

```json
{
  "id": "ws-itops",
  "slug": "it-operations",
  "name": "IT operations",
  "accent_color": "#5b8af0",
  "createdAt": "2026-09-01T08:00:00.000Z",
  "register": {
    "name": "IT operations",
    "createdAt": "2026-09-01T08:00:00.000Z",
    "risks": [ ],
    "refSeq": 0
  },
  "prefs": {
    "filterQuery": "",
    "filterStatus": "all",
    "minScore": 0,
    "activeView": "all",
    "viewSort": {
      "all": { "col": "ref", "dir": "asc" },
      "by-status": { "col": "inherent", "dir": "desc" },
      "heatmap": { "col": "ref", "dir": "asc" }
    }
  }
}
```

| Field | Type | Notes | If missing |
|---|---|---|---|
| `id` | string | Any non-empty string, unique in the file. The app makes ids like `ws-k3j9x2ab1f4c`. | A new id is generated. |
| `slug` | string | The workspace's identity. Lower-case `a-z`, `0-9` and `-`, at most 32 characters. **Must be unique in the file**; the importer does not check (§8). | Derived from `name`. |
| `name` | string | The tab label. | `"Risk register"` |
| `accent_color` | `"#rrggbb"` | The tab's dot color. The app's palette is `#5b8af0`, `#5ecc94`, `#d4a464`, `#e05050`, `#a880e8`, `#4ec9b0`, `#e06090`, `#c8b830`. | `"#5b8af0"` |
| `createdAt` | ISO 8601 datetime | Shown in the workspace's About dialog. | The moment of import. |
| `register` | object | See §3.1. | An empty register. |
| `prefs` | object | See §3.2. | The defaults shown above. |

**Array order is tab order.** **Unknown keys on a workspace are dropped** on
import.

### 3.1 `register`

| Field | Type | Notes |
|---|---|---|
| `name` | string | A copy of the workspace `name`. Keep the two identical; renaming a tab in the app sets both. Missing means `"Risk register"`. |
| `createdAt` | ISO 8601 datetime | Missing means the moment of import. |
| `risks` | array of risk objects | See §4. `[]` when empty. **Must be an array**; anything else fails the import. |
| `refSeq` | integer ≥ 0 | The last ref number handed out in this workspace. See §6.3. Missing or negative means `0`. |

Unknown keys on `register` are kept, but have no effect.

### 3.2 `prefs`

These are the workspace's **live view settings**. They are applied as-is, so
a filter in the file is a filter the user opens to. For a generated pack, use
exactly the defaults in the skeleton above.

| Field | Values | Default |
|---|---|---|
| `filterQuery` | Search text, matched against ref, title, description, owner, raised-by, category and treatment. | `""` |
| `filterStatus` | `"all"`, or one status id from §4.1. | `"all"` |
| `minScore` | Minimum inherent score shown: `0` (any), `4` (Moderate and up), `10` (High and up), `16` (Critical only). | `0` |
| `activeView` | `"all"` (flat list), `"by-status"` (grouped by status) or `"heatmap"`. | `"all"` |
| `viewSort` | Keyed by view id. Each value is `{ "col": ..., "dir": "asc" \| "desc" }`. | As in the skeleton. |

Sort columns (`col`): `ref`, `title`, `category`, `owner`, `raisedBy`,
`raisedAt`, `strategy`, `status`, `inherent`, `residual`, `updated`.

A `prefs` object with some keys missing has them filled from the defaults.
Unknown keys are kept, but have no effect.

---

## 4. The risk record

```json
{
  "id": "r-itops-01",
  "ref": "RR-001",
  "title": "Backups are not tested for restore",
  "description": "Nightly backups complete, but nobody has restored from them.",
  "category": "Resilience",
  "owner": "Dana Whitfield",
  "raisedBy": "Internal audit",
  "raisedAt": "2026-09-02",
  "status": "open",
  "inherent": { "likelihood": 3, "impact": 5 },
  "responseStrategy": "reduce",
  "treatment": "Restore one production database to test each quarter.",
  "residual": { "likelihood": 2, "impact": 4 },
  "createdAt": "2026-09-02T10:15:00.000Z",
  "updatedAt": "2026-09-20T14:30:00.000Z"
}
```

The importer **rebuilds every risk from the fields below**. Any other key on a
risk is dropped.

| Field | Type | Meaning | If missing or invalid |
|---|---|---|---|
| `id` | string | Unique within the workspace. The app makes ids like `r-260928.091500` (UTC date and time, with `-1`, `-2` added on a clash); short readable ids work just as well. | A new id is minted from the import time. A **duplicate** id is replaced with a new one on the later risk. |
| `ref` | `"RR-"` + digits | The human-citable reference. See §6.3. Upper case, exactly this form. | Re-stamped with the next free number. |
| `title` | string | One line. | `""`, shown as *(untitled)*. |
| `description` | string, Markdown | The risk in full. `\n` for line breaks. | `""` |
| `category` | string | Free text, e.g. `"Resilience"`. | `""` |
| `owner` | string | Free text: who owns the risk. | `""` |
| `raisedBy` | string | Free text: a person, team or source such as `"Internal audit"`. | `""` |
| `raisedAt` | `"YYYY-MM-DD"` | The day the risk was raised. | The calendar date of `createdAt` in the importing browser's time zone, or the day of import. |
| `status` | enum, §4.1 | Where work on the risk stands. | `"open"` |
| `inherent` | `{ likelihood, impact }` | Exposure before treatment. Integers 1 to 5. See §6.1. | Each missing or non-numeric value becomes `1`. |
| `responseStrategy` | enum, §4.2 | The chosen approach. `""` means not yet decided. | `""` |
| `treatment` | string, Markdown | The treatment plan. | `""` |
| `residual` | `null` or `{ likelihood, impact }` | Exposure after treatment. `null` means not yet scored. | `null`, also when **either** number is missing. |
| `createdAt` | ISO 8601 datetime | When the record was made. | The moment of import. |
| `updatedAt` | ISO 8601 datetime | When it last changed. Drives the stale badge (§6.4). | `createdAt`. |

Free-text fields (`category`, `owner`, `raisedBy`) feed the app's
autocomplete, which suggests values already used in the same workspace. Spell
each value identically everywhere, or it shows up as two.

**Optional origin fields.** A risk that arrived from a PumaGRC2 or PumaTTX
pack also carries `sourceApp`, `sourceControlId` and `sourceAssessmentId`
(strings). They let a later import of the same source update the risk instead
of duplicating it. **Keep them exactly as they are** when editing, and never
add them to a risk you write yourself. Empty values are removed.

### 4.1 `status`

| Id | Shown as | Meaning |
|---|---|---|
| `open` | Open | Identified; not yet dealt with. |
| `mitigated` | Mitigated | Controls are in place. |
| `accepted` | Accepted | Tolerated as it stands. |
| `transferred` | Transferred | Shifted to a third party (insurance, contract, vendor). |
| `avoided` | Avoided | The activity causing it was stopped or changed. |

Anything else, including `"Open"` or `"in-progress"`, **silently becomes
`"open"`**.

### 4.2 `responseStrategy`

| Id | Shown as | Meaning |
|---|---|---|
| `""` | — | Not yet decided. |
| `avoid` | Avoid | Stop or change the activity that creates the risk. |
| `reduce` | Reduce | Add controls to lower likelihood or impact. |
| `transfer` | Transfer | Shift to a third party: insurance, contract, vendor. |
| `accept` | Accept | Acknowledge and tolerate; no further action. |

Anything else, including `"Reduce"`, **silently becomes `""`**.

Strategy and status are separate: the strategy is the plan, the status is
where the work stands. A risk can be `reduce` while still `open`. Pairs that
read naturally once the work is done:

| `responseStrategy` | finished `status` |
|---|---|
| `reduce` | `mitigated` |
| `accept` | `accepted` |
| `transfer` | `transferred` |
| `avoid` | `avoided` |

The app does not enforce these pairs.

---

## 5. Cross-references

PumaRisk records hardly refer to each other.

| From | Field | To |
|---|---|---|
| envelope | `data.activeSlug` | `data.workspaces[].slug` |
| register | `refSeq` | at least the highest number in that workspace's `risks[].ref` |
| risk | `sourceApp`, `sourceControlId`, `sourceAssessmentId` | a finding in another app's pack; not resolved within this file |

- **Refs are per workspace.** Two workspaces can each have an `RR-001`.
- No risk points at another risk by id. Writing `RR-002` inside a description
  is plain text; it is not turned into a link.

---

## 6. Scoring, refs and dates

### 6.1 Likelihood, impact and score

- `likelihood` and `impact` are **whole numbers from 1 to 5**, 1 lowest.
- On import, each value is converted to a number and then held to that range:
  missing, `0`, `null` or non-numeric becomes `1`; above 5 becomes `5`;
  decimals are rounded; a numeric string such as `"3"` is read as `3`. Write
  plain integers.
- **Score** = likelihood × impact, from 1 to 25. It is **derived, never
  stored**; do not add a score field.

| Score | Tier |
|---|---|
| 16 to 25 | Critical |
| 10 to 15 | High |
| 4 to 9 | Moderate |
| 1 to 3 | Low |

- Tiers, the heatmap, and the minimum-score filter all use the **inherent**
  score.
- The residual score is shown beside the inherent one, marked as down, level
  or up. A residual higher than the inherent is allowed and shown as up.
- The summary above the list counts risks per tier, and also counts
  *"open without strategy"*: risks with `status: "open"` and
  `responseStrategy: ""`.

### 6.2 Order

- Workspace array order is tab order.
- Risk array order is kept on import and on export, but the list is always
  sorted by the view's sort (by ref, by default), so it does not affect what
  the user sees.

### 6.3 Refs and `refSeq`

- **Format:** `RR-` then a number. The app pads to three digits (`RR-001` …
  `RR-999`, then `RR-1000`). The importer accepts any digits after `RR-`, but
  anything else (`rr-1`, `RR01`, `R-001`) counts as missing.
- **Numbering:** start at `RR-001` in each workspace. Gaps are normal; a
  deleted risk's number is never reused.
- **`refSeq`** is the last number handed out in the workspace. The next risk
  added in the app gets `refSeq + 1`. Set it to **at least the highest ref
  number present**.
- **What the importer does:**
  - If every risk has a valid ref, `refSeq` is raised to the highest ref if it
    was lower.
  - If some risks lack a valid ref, those are numbered from `refSeq + 1`
    upward, oldest `createdAt` first, and `refSeq` advances with them. In this
    case `refSeq` is **not** first raised to the highest existing ref, so a low
    `refSeq` produces **duplicate refs** (§8).
  - Duplicate refs are not detected. Don't write them.

### 6.4 Dates and times

- **`raisedAt`** is exactly `YYYY-MM-DD`, e.g. `"2026-09-02"`. It is a plain
  calendar day with no time zone. Any other form is replaced (see §4).
- **`createdAt`, `updatedAt`** (on risks, workspaces and registers) are full
  ISO 8601 datetimes, e.g. `"2026-09-02T10:15:00.000Z"`. They are not
  validated, so a malformed one is kept and quietly breaks sorting and the
  stale badge.
- **Stale badge.** A risk whose `updatedAt` (or, failing that, `createdAt`) is
  30 or more days before today is dimmed in the list and badged with its age,
  e.g. `45d`. A generated pack with old timestamps opens with every row dimmed.
  Give current risks a recent `updatedAt`.

---

## 7. Editing an existing export

Start from a **full backup** (topbar **Export**). Edit the JSON, then import it
back; it replaces what the app holds.

**Preserve exactly:**

- every `id`, workspace and risk;
- every workspace `slug`;
- every existing `ref`. People cite refs in audits, minutes and tickets, so an
  existing risk's ref never changes;
- `refSeq`. It may go up, never down;
- `createdAt` on everything;
- the origin fields `sourceApp`, `sourceControlId`, `sourceAssessmentId`;
- `prefs`, unless the user asked for a different view.

**When you change a risk,** set its `updatedAt` to the current time. The app
does the same on every edit, and the stale badge depends on it.

**To add a risk:**

1. Give it a new `id`, unique in its workspace.
2. Give it `ref` = `RR-` + (`refSeq` + 1), padded to three digits.
3. Increase that workspace's `refSeq` by one.
4. Set `createdAt` and `updatedAt` to now, and `raisedAt` to today.
5. Write every field from §4.

**To delete a risk,** remove it from the array. Do not lower `refSeq`, and do
not renumber the others.

**To move a risk to another workspace,** do what the app does: give it the
next ref from the **destination's** `refSeq` (and increase that), and change
its `id` if the destination already uses it.

**What the app regenerates** when it exports again: `puma.exportedAt`,
`puma.appVersion`, and `data.prefs` (from the device's current theme and
accent). Everything else is written back exactly as imported.

Nothing in the file is hashed or signed, so any field can be edited. The ones
that must not be hand-changed are the identity fields above.

---

## 8. Things that go wrong

| Mistake | What actually happens |
|---|---|
| No `puma` envelope | Rejected: *"Not a .pumapack file — missing the puma envelope."* |
| `puma.app` missing or not `"pumarisk"` | Rejected with the "different app" message (§2.1). |
| `data.workspaces` empty | Rejected: *"Import failed — no workspaces found."* |
| Importing a single-workspace export | Accepted, and **every other workspace is gone**. |
| `register.risks` not an array | Rejected: *"Import failed — the backup file is malformed or corrupt."* |
| `null` inside `risks` | Rejected with the same *malformed or corrupt* message. |
| `register` a string or number | Rejected with the same *malformed or corrupt* message. |
| `register: null` | Accepted as an empty register named *Risk register*. |
| Enum typo, e.g. `"status": "in-progress"` or `"responseStrategy": "Reduce"` | Silently reset to `"open"` / `""`. |
| `likelihood: 7`, `0` or missing | Held to 1 to 5: `7` becomes `5`; `0` or missing becomes `1`. |
| `residual` with only one number | The whole residual becomes `null`. |
| `raisedAt` as `"02/09/2026"` | Replaced by the date of `createdAt`. |
| Ref missing or malformed, e.g. `"rr-2"` | Re-stamped with the next free number. |
| Ref missing **and** `refSeq` lower than the highest ref | The new ref **duplicates** an existing one, and `refSeq` is left too low, so the next risk added in the app duplicates another. |
| Two risks with the same `id` | The later one gets a new id. |
| Risk with no `id` | Gets a new id. |
| Two workspaces with the same `slug` | Both import, both tabs show as selected, and the **second can never be opened**. |
| `data.activeSlug` matching no workspace | The first workspace opens. |
| Unknown key on a risk or a workspace | Dropped. |
| Unknown key on `register`, `prefs` or the envelope | Kept or ignored; no effect. |
| A non-default filter in `prefs` | The user opens the workspace to a filtered list. |
| Old `updatedAt` values | Every such row opens dimmed with a stale badge. |

---

## 9. Checklist before handing a pack over

A pack that passes all of these imports with no warnings and needs no
re-stamping.

**Structure**
- [ ] The envelope matches §2: `puma.app` is `"pumarisk"`, `puma.format` is
      `2`, and `data.workspaces` is a non-empty array.
- [ ] Every workspace has `id`, `slug`, `name`, `accent_color`, `createdAt`,
      `register` and `prefs`.
- [ ] Every risk has every field from §4, and no `null` except `residual`.
- [ ] `data.activeSlug` names one of the workspaces.

**Identity**
- [ ] Workspace slugs are unique across the file.
- [ ] Risk ids are unique within each workspace.
- [ ] Each workspace's refs are `RR-NNN`, unique, and `refSeq` is at least the
      highest number used.
- [ ] When editing: no existing id, slug, ref or `createdAt` changed, and
      `refSeq` did not go down.

**Values**
- [ ] `status` and `responseStrategy` use the exact ids in §4.1 and §4.2.
- [ ] `likelihood` and `impact` are integers 1 to 5, in `inherent` and in a
      non-null `residual`.
- [ ] `raisedAt` is `YYYY-MM-DD`; `createdAt`, `updatedAt` and `exportedAt`
      are full ISO datetimes.
- [ ] `prefs` uses the defaults, unless a view was asked for.

**Before import**
- [ ] The user knows import replaces every workspace they have now.

---

## 10. A complete example

A full backup with two workspaces. It uses every status, every response
strategy, a scored and an unscored residual, Markdown in the text fields, and
a gap in the refs where a risk was deleted. It imports with no warnings.

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumarisk",
    "appVersion": "generated",
    "format": 2,
    "exportedAt": "2026-09-28T09:00:00.000Z",
    "kind": "backup",
    "title": "PumaRisk backup"
  },
  "data": {
    "workspaces": [
      {
        "id": "ws-itops",
        "slug": "it-operations",
        "name": "IT operations",
        "accent_color": "#5b8af0",
        "createdAt": "2026-09-01T08:00:00.000Z",
        "register": {
          "name": "IT operations",
          "createdAt": "2026-09-01T08:00:00.000Z",
          "risks": [
            {
              "id": "r-itops-01",
              "ref": "RR-001",
              "title": "Backups are not tested for restore",
              "description": "Nightly backups complete, but nobody has restored from them since the storage migration.\n\n- No restore test in the last **12 months**\n- The runbook still names the old storage array",
              "category": "Resilience",
              "owner": "Dana Whitfield",
              "raisedBy": "Internal audit",
              "raisedAt": "2026-09-02",
              "status": "open",
              "inherent": { "likelihood": 3, "impact": 5 },
              "responseStrategy": "reduce",
              "treatment": "1. Restore one production database to the test environment each quarter\n2. Update the runbook for the new array",
              "residual": { "likelihood": 2, "impact": 4 },
              "createdAt": "2026-09-02T10:15:00.000Z",
              "updatedAt": "2026-09-20T14:30:00.000Z"
            },
            {
              "id": "r-itops-02",
              "ref": "RR-002",
              "title": "Single administrator for the firewall cluster",
              "description": "Only one engineer holds the firewall admin credentials and knows the rule base.",
              "category": "People",
              "owner": "Dana Whitfield",
              "raisedBy": "Sam Ortega",
              "raisedAt": "2026-09-05",
              "status": "mitigated",
              "inherent": { "likelihood": 4, "impact": 4 },
              "responseStrategy": "reduce",
              "treatment": "Second engineer trained and given break-glass access; rule base documented.",
              "residual": { "likelihood": 2, "impact": 3 },
              "createdAt": "2026-09-05T09:00:00.000Z",
              "updatedAt": "2026-09-25T16:00:00.000Z"
            },
            {
              "id": "r-itops-03",
              "ref": "RR-003",
              "title": "Legacy file server runs an unsupported OS",
              "description": "The archive file server is on an operating system past end of support. It is read-only and isolated on its own VLAN.",
              "category": "Infrastructure",
              "owner": "Sam Ortega",
              "raisedBy": "Sam Ortega",
              "raisedAt": "2026-09-10",
              "status": "accepted",
              "inherent": { "likelihood": 2, "impact": 2 },
              "responseStrategy": "accept",
              "treatment": "",
              "residual": null,
              "createdAt": "2026-09-10T11:00:00.000Z",
              "updatedAt": "2026-09-10T11:00:00.000Z"
            }
          ],
          "refSeq": 3
        },
        "prefs": {
          "filterQuery": "",
          "filterStatus": "all",
          "minScore": 0,
          "activeView": "all",
          "viewSort": {
            "all": { "col": "ref", "dir": "asc" },
            "by-status": { "col": "inherent", "dir": "desc" },
            "heatmap": { "col": "ref", "dir": "asc" }
          }
        }
      },
      {
        "id": "ws-thirdparty",
        "slug": "third-party",
        "name": "Third-party",
        "accent_color": "#5ecc94",
        "createdAt": "2026-09-01T08:05:00.000Z",
        "register": {
          "name": "Third-party",
          "createdAt": "2026-09-01T08:05:00.000Z",
          "risks": [
            {
              "id": "r-tp-01",
              "ref": "RR-001",
              "title": "Payroll provider outage during pay run",
              "description": "Payroll is fully outsourced. A provider outage in the two days before a pay run would delay salaries.",
              "category": "Supplier",
              "owner": "Priya Shah",
              "raisedBy": "Priya Shah",
              "raisedAt": "2026-09-03",
              "status": "transferred",
              "inherent": { "likelihood": 2, "impact": 4 },
              "responseStrategy": "transfer",
              "treatment": "Contract now carries a service credit and a manual-run fallback at the provider's cost.",
              "residual": { "likelihood": 2, "impact": 2 },
              "createdAt": "2026-09-03T13:00:00.000Z",
              "updatedAt": "2026-09-18T09:45:00.000Z"
            },
            {
              "id": "r-tp-03",
              "ref": "RR-003",
              "title": "Marketing agency stores customer lists on personal drives",
              "description": "The agency asked for a customer export to build a campaign audience.",
              "category": "Data protection",
              "owner": "Priya Shah",
              "raisedBy": "Legal",
              "raisedAt": "2026-09-12",
              "status": "avoided",
              "inherent": { "likelihood": 3, "impact": 4 },
              "responseStrategy": "avoid",
              "treatment": "No customer data leaves the company. The agency builds audiences inside our ad platform account instead.",
              "residual": null,
              "createdAt": "2026-09-12T15:20:00.000Z",
              "updatedAt": "2026-09-15T10:00:00.000Z"
            }
          ],
          "refSeq": 3
        },
        "prefs": {
          "filterQuery": "",
          "filterStatus": "all",
          "minScore": 0,
          "activeView": "all",
          "viewSort": {
            "all": { "col": "ref", "dir": "asc" },
            "by-status": { "col": "inherent", "dir": "desc" },
            "heatmap": { "col": "ref", "dir": "asc" }
          }
        }
      }
    ],
    "activeSlug": "it-operations",
    "prefs": { "theme": null, "accent": null }
  }
}
```

What the app does with this, as a check on your own reasoning. These results
were produced by importing this exact file into the app:

- The toast reads *"Imported 2 workspaces (5 risks)."* On a fresh install
  there is no confirmation dialog, because there are no risks to replace.
- Two tabs, **IT operations** (open) and **Third-party**. Every id, ref,
  `refSeq`, date and text field is stored exactly as written, and a fresh
  export writes the same data back apart from `exportedAt` and `appVersion`.
- IT operations' summary reads *3 risks · 1 Critical · 1 High · 1 Moderate*:
  - `RR-002` is Critical, 4 × 4 = 16, with a residual of 6;
  - `RR-001` is High, 3 × 5 = 15, with a residual of 8;
  - `RR-003` is Moderate, 2 × 2 = 4, with no residual.
- No risk is *open without strategy*, because the one open risk has
  `reduce`.
- Third-party holds `RR-001` (Moderate, 8) and `RR-003` (High, 12). `RR-002`
  was deleted; the gap stays.
- In both workspaces the next risk added becomes `RR-004`.
- The theme and accent color are left as the user had them.
- From 30 days after each `updatedAt`, that row shows dimmed with a stale
  badge.
