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

This document describes PumaBCP's `.pumapack` files in enough detail to
**edit an exported plan** or **generate one from scratch** so that it imports
cleanly: no error, no dropped data and no records re-stamped. 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. PumaBCP imports it in either of two ways:

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

Import always **adds** plans as new workspaces. It never replaces or merges
into a plan that is already open.

PumaBCP has no separate data schema number. The envelope's `puma.format` is
`1`, and the app's Storage and stats panel reports "Schema version v1". Both
mean the shape described here.

---

## 1. The short version

If you only read one section, read this one.

1. Use the single-plan envelope from §2: `puma` plus `data`, with the plan's
   records directly under `data`. A file with no `puma` key is rejected.
2. Give every register as an **array**: `assets`, `dependencies`,
   `dismissedDeps`, `backupInfos`, `workflows`, `scenarios`, `history`. Use
   `[]` for "none". Never put `null` inside an array, and never use a string or
   object where an array goes (§8).
3. Write **every field** of every record, using the shapes in §4. Use `""`
   for "nothing" in text and date fields. Never write `null`.
4. Give every record an `id` that is unique within its array. Every
   `…AssetId` / `assetIds` value must be the `id` of an asset in the same plan
   (§5).
5. Write **at most one** backup record per asset, and set `backupMethod` to
   match `hasBackup` (§4.2).
6. Use the exact enum spellings in §4, including capitals: `"Critical"`, not
   `"critical"`. Nothing is validated on import.
7. Dates are `"YYYY-MM-DD"`. Record timestamps (`createdAt`, `updatedAt`) are
   whole numbers of **milliseconds since 1970**, not ISO strings.
8. Recovery steps are numbered `1, 2, 3 …` in array order.
9. Check the result against the checklist in §9.

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

---

## 2. The envelope (single plan)

This is what **Save workspace (.pumapack)** writes, from the menu on a
workspace tab (right-click, or long-press on touch). It holds one plan. It is
the shape to use for editing or generating a plan.

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumabcp",
    "appVersion": "generated",
    "format": 1,
    "exportedAt": "2026-09-28T09:00:00.000Z",
    "title": "Harbor Street Bakery continuity plan"
  },
  "data": {
    "plan": { "id": "plan-1", "name": "Harbor Street Bakery continuity plan", "businessName": "Harbor Street Bakery" },
    "assets":        [ ],
    "dependencies":  [ ],
    "dismissedDeps": [ ],
    "backupInfos":   [ ],
    "workflows":     [ ],
    "scenarios":     [ ],
    "history":       [ ]
  }
}
```

| Key | Value | Notes |
|---|---|---|
| `$schema` | `"https://pumaworx.dev/pumapack/v1"` | Not checked on import. Write it. |
| `puma.app` | `"pumabcp"` | Checked. Any other value makes the app ask first (see below). If the key is missing, no question is asked. |
| `puma.appVersion` | any string | Informational. The app writes the build it came from. |
| `puma.format` | `1` | Not checked. Write `1`. |
| `puma.exportedAt` | ISO 8601 datetime | Informational. |
| `puma.title` | string | Used as the plan name only when `data.plan.name` is empty or missing. |
| `data.plan` | object | See §3. |
| `data.assets` … `data.history` | arrays | See §4. |

What the importer actually requires:

- The file is valid JSON. If not: *"That doesn’t look like a JSON or
  .pumapack backup"*.
- The top level has both a `puma` object and a `data` object. If not:
  *"Unrecognized file format"*.
  - A bare plan with no envelope, or a file with `data` but no `puma`, is
    rejected this way.
- If `puma.app` is present and is not `"pumabcp"`, the app asks: *"This
  .pumapack was made by "…", not PumaBCP. Try to import anyway?"* Cancel
  stops the import with no message. OK reads the file as a PumaBCP plan.

Any other envelope key is ignored.

On success the app says *"Imported N assets, N dependencies, N scenarios"*,
opens the new plan and shows its Dashboard.

On import:

- The plan gets a **new id**. `data.plan.id` is ignored, so importing the same
  file twice gives two separate workspaces.
- The plan's own created and updated times are set to the moment of import.
- Every record inside the plan is stored **exactly as written**: ids,
  timestamps and any extra keys included. Nothing is re-numbered, re-stamped
  or filled in.
- Only the keys listed above are read from `data`. **Any other key under
  `data`, or under `data.plan`, is silently dropped.**
- A register that is missing or `null` is stored as `[]`.

PumaBCP does not read other apps' packs. A pack from another PumaWorx app
passes the question above only if the user accepts it, and is then read as if
it were a PumaBCP plan, which usually gives an empty plan. The app also
accepts an older plain-JSON export (a top-level `"appName": "resilience"`);
do not generate that shape.

---

## 3. The plan and the full backup

### 3.1 `data.plan`

```json
{ "id": "plan-1", "name": "Harbor Street Bakery continuity plan", "businessName": "Harbor Street Bakery" }
```

| Field | Type | Meaning |
|---|---|---|
| `id` | string | Ignored on import; the app assigns a new one. Keep whatever an export gave you. |
| `name` | string | The workspace tab's name. If empty, `puma.title` is used, then `"Imported plan"`. |
| `businessName` | string | Shown under the Dashboard title and in the report headings. `""` if none. |

### 3.2 The full backup (a second shape)

The topbar **Export full backup** button (also `⌘S` / `Ctrl-S`) writes every
plan on the device into one file. It imports too, but it is a different shape:

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumabcp", "appVersion": "generated", "format": 1,
    "kind": "full-backup",
    "exportedAt": "2026-09-28T09:00:00.000Z",
    "title": "PumaBCP — full backup (1 plan)"
  },
  "data": {
    "plans": [
      { "plan": { }, "assets": [ ], "dependencies": [ ], "dismissedDeps": [ ],
        "backupInfos": [ ], "workflows": [ ], "scenarios": [ ], "history": [ ] }
    ],
    "prefs": { "theme": null, "accent": null }
  }
}
```

- It is recognized by **`puma.kind: "full-backup"` together with a
  `data.plans` array**. Each element of `data.plans` holds exactly what
  `data` holds in the single-plan shape.
- Every plan in it is added as a **new** workspace, with a new id. The first
  one becomes the open plan.
- The success message reads like *"Restored 1 plan · 5 assets · 2
  scenarios"*.
- `prefs.theme` is `"light"`, `"dark"` or `null`. `prefs.accent` is a
  `#rrggbb` color or `null`. When set, they replace the user's theme and
  accent color. Write `null` for both unless you mean to change them.
- **Restoring a full backup re-adds every plan in it.** If you were handed a
  full backup to change one plan, the user gets a second copy of every plan.
  To hand back one changed plan, take that element of `data.plans` and wrap
  it in the single-plan envelope from §2.
- A file with `data.plans` but no `"kind": "full-backup"` is read as a single
  plan, finds no registers, and imports **one empty plan**.

The rest of this document describes one plan's registers. They are the same
in both shapes.

---

## 4. Record shapes

### Conventions for every record

- **`id`** is any non-empty string, unique within its own array in the plan.
  - The app generates UUIDs such as `"9f1c2e7a-…"`. Short readable ids
    (`"ast-router"`, `"dep-1"`) work just as well.
  - Ids need not be unique across plans.
- **Text** fields are strings. `""` means empty. Fields marked *Markdown* are
  edited with a Markdown editor and plain text is fine there too.
- **Dates** are `"YYYY-MM-DD"` or `""`.
- **Timestamps** (`createdAt`, `updatedAt`) are numbers of milliseconds since
  1970-01-01 UTC, such as `1790582400000`. The app never changes them on
  import.
- **Enum values are not validated.** A misspelled or wrongly capitalized value
  is kept, shows blank in the editor's dropdown, and drops out of every count
  that looks for the right value.
- **Extra keys on a record are kept** and exported again, but have no effect.

### 4.1 `assets[]`

The technology the business runs on.

```json
{
  "id": "ast-square", "name": "Square", "category": "SaaS", "criticality": "Critical",
  "vendor": "Block", "vendorSupportUrl": "https://squareup.com/help", "vendorSupportPhone": "1-855-700-6000",
  "ownerName": "Dana Okafor", "ownerEmail": "dana@harborstreet.example", "ownerPhone": "555-0101",
  "notes": "Card payments and the item library.",
  "createdAt": 1790582400000, "updatedAt": 1790582400000
}
```

| Field | Type | Meaning |
|---|---|---|
| `name` | string | **Required and non-empty.** An asset with no name breaks the Dependencies view. |
| `category` | enum | `"SaaS"`, `"Hardware"`, `"Cloud"`, `"On-Prem"`, `"Network"`, `"Data Store"` or `"Other"`. |
| `criticality` | enum | `"Critical"`, `"High"`, `"Medium"` or `"Low"`. See §6 for what `Critical` drives. |
| `vendor`, `vendorSupportUrl`, `vendorSupportPhone` | strings | Listed under "Vendor support contacts" in the reports. |
| `ownerName`, `ownerEmail`, `ownerPhone` | strings | The person who looks after it. Listed in the report's contact directory. See §6.6. |
| `notes` | string, Markdown | Account numbers, contract dates, quirks. |
| `createdAt`, `updatedAt` | timestamps | |

The asset's backup is **not** stored on the asset. It lives in `backupInfos`.

### 4.2 `backupInfos[]`

One record per asset, describing its backup. The app edits it inside the asset
editor, but stores it in its own array.

```json
{
  "id": "bak-ast-internet", "assetId": "ast-internet",
  "hasBackup": "Yes", "backupMethod": "Self-managed",
  "backupLocation": "Phone hotspot in the office drawer",
  "backupFrequency": "Always on standby",
  "lastVerifiedDate": "2026-09-01", "lastTestRestoreDate": "2026-09-01",
  "restoreSteps": "1. Turn on the hotspot\n2. Join both registers to the hotspot network",
  "estimatedRestoreTime": "10 minutes"
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | string | The app uses `"bak-"` followed by the asset's id. Any unique string works; the app finds a backup by `assetId`, not by `id`. |
| `assetId` | asset id | The asset this describes. **At most one record per asset.** |
| `hasBackup` | enum | `"Yes"`, `"No"` or `"Unknown"`. |
| `backupMethod` | enum | `"Vendor-managed"`, `"Self-managed"`, `"Manual Export"`, `"None"` or `"Unknown"`. See the pairing rule below. |
| `backupLocation` | string | Where the copy lives. |
| `backupFrequency` | string | Free text: `"Nightly"`, `"Continuous"`. |
| `lastVerifiedDate` | date or `""` | When someone last confirmed the backup exists. Shown, not scored. |
| `lastTestRestoreDate` | date or `""` | When a restore was last actually tested. **This is what the backup state is scored on.** See §6.1. |
| `restoreSteps` | string, Markdown | How to get it back. Use `\n` between steps. |
| `estimatedRestoreTime` | string | Free text: `"10 minutes"`, `"half a day"`. |

The pairing the app itself writes, which a generated record should follow:

| `hasBackup` | `backupMethod` | The other fields |
|---|---|---|
| `"Yes"` | one of `Vendor-managed`, `Self-managed`, `Manual Export` | filled in as known |
| `"No"` | `"None"` | all `""` |
| `"Unknown"` | `"Unknown"` | all `""` |

An asset with **no** backup record is treated as `Unknown`.

### 4.3 `dependencies[]`

"If the upstream asset fails, the downstream asset is affected."

```json
{
  "id": "dep-1", "upstreamAssetId": "ast-internet", "downstreamAssetId": "ast-square",
  "dependencyType": "Hard", "description": "Cloud services are unreachable without the line.",
  "createdAt": 1790582400000
}
```

| Field | Type | Meaning |
|---|---|---|
| `upstreamAssetId` | asset id | The thing depended on. |
| `downstreamAssetId` | asset id | The thing that is hit when upstream fails. Must differ from `upstreamAssetId`. |
| `dependencyType` | enum | `"Hard"` (downstream stops completely), `"Soft"` (degraded but working) or `"Optional"` (nice to have). See §6.3. |
| `description` | string | One line of why. |
| `createdAt` | timestamp | Dependencies have no `updatedAt`. |

- Write each (upstream, downstream) pair **once**. The app refuses a duplicate
  when a user adds one, but the importer keeps it.
- A pair recorded in both directions (A needs B, and B needs A) is allowed and
  means the two have no safe restart order. Record it only when it is true.

### 4.4 `dismissedDeps[]`

An array of **strings**, each `"<upstream asset id>>" + "<downstream asset id>"`
joined by a single `>`, for example `"ast-internet>ast-pos"`.

The Dependencies view suggests links between assets whose names or categories
it recognizes (for example, an asset named `Internet Connection` is proposed
as upstream of every `SaaS` asset). The user keeps or dismisses each
suggestion. A dismissed one is remembered here so it is not proposed again.

- `[]` is always safe. The user then reviews any suggestions in the app.
- A suggestion also stops appearing once the pair is mapped in `dependencies`
  in **either** direction.
- While any suggestion is unreviewed, the readiness step *Map what depends on
  what* stays open (§6.4). That is a prompt, not an error.

### 4.5 `workflows[]`

The business processes that matter, and the assets each one needs.

```json
{
  "id": "wf-sales", "name": "Take payment at the counter",
  "description": "Ring up orders and take card or cash at the front counter.",
  "criticality": "Critical", "maxTolerableDowntime": "30 minutes",
  "revenueImpactDescription": "About $400 an hour in lost sales on a weekday morning",
  "manualWorkaround": "Cash only, with a handwritten receipt book.",
  "assetIds": ["ast-square", "ast-pos"],
  "createdAt": 1790582400000, "updatedAt": 1790582400000
}
```

| Field | Type | Meaning |
|---|---|---|
| `name` | string | Required, non-empty. |
| `description` | string | What the process involves. |
| `criticality` | enum | Same values as assets: `"Critical"`, `"High"`, `"Medium"`, `"Low"`. |
| `maxTolerableDowntime` | string | Free text, answering "How quickly do you need it back?": `"4 hours"`, `"same day"`. |
| `revenueImpactDescription` | string | Free text. |
| `manualWorkaround` | string | The pen-and-paper fallback. |
| `assetIds` | array of asset ids | The assets the process depends on. **Must be an array.** Order does not matter. |
| `createdAt`, `updatedAt` | timestamps | |

### 4.6 `scenarios[]`

Tabletop exercises. The app walks each one through seven steps: Setup,
Impact, Response, Recovery, Backups, Gaps, Actions.

```json
{
  "id": "scn-internet", "name": "Internet down on a Saturday morning",
  "scenarioType": "Service Outage",
  "triggerDescription": "At 7:30 on a Saturday the fiber line drops.",
  "initiallyAffectedAssetIds": ["ast-internet"],
  "discussionPrompts": ["How long can the registers keep taking cards offline?"],
  "recoverySteps": [
    { "id": "rs-1", "stepNumber": 1, "action": "Switch both registers to the phone hotspot",
      "responsiblePerson": "Luis Ferreira", "estimatedDuration": "10 minutes", "notes": "" }
  ],
  "exerciseNotes": "",
  "status": "Completed",
  "createdAt": 1790582400000, "updatedAt": 1790582400000
}
```

| Field | Type | Meaning |
|---|---|---|
| `name` | string | The scenario's title. |
| `scenarioType` | string | A label. The app's templates use `"Ransomware"`, `"Service Outage"`, `"Vendor Failure"`, `"Key Person Loss"` and `"Physical Disaster"`; prefer those. Other text is kept and shown, and **Send to PumaTTX** treats it as a technology outage. |
| `triggerDescription` | string | The situation, read aloud to start the exercise. Blank lines (`\n\n`) separate paragraphs. |
| `initiallyAffectedAssetIds` | array of asset ids | The assets hit first. The Impact step follows the dependency map downstream from these. `[]` until the team chooses. |
| `discussionPrompts` | array of strings | Questions for the Response step, one per string. |
| `recoverySteps` | array of steps | See below. `[]` if none yet. |
| `exerciseNotes` | string, Markdown | What was discussed and the action items. One field, shown on both the Response and Actions steps. |
| `status` | enum | `"Not Started"`, `"In Progress"` or `"Completed"`. |
| `createdAt`, `updatedAt` | timestamps | The app does not change the scenario's `updatedAt` when an exercise is completed. **Send to PumaTracker** uses it as the completion time. |

- Opening a `"Not Started"` scenario in the app changes it to
  `"In Progress"`. The **Complete exercise** button sets `"Completed"`.
- A `"Completed"` scenario with empty `exerciseNotes` is allowed, but reads
  as an exercise with nothing learned.

Each element of `recoverySteps`:

| Field | Type | Meaning |
|---|---|---|
| `id` | string | Unique within the scenario's steps. |
| `stepNumber` | integer | **Its position, starting at 1**, with no gaps. The app shows steps in array order and renumbers them this way whenever one is deleted. |
| `action` | string | What needs to happen. |
| `responsiblePerson` | string | Who does it. See §6.6. |
| `estimatedDuration` | string | Free text: `"10 minutes"`. |
| `notes` | string | Printed in the Markdown, RTF and PDF reports. The app has no field to edit it. |

### 4.7 `history[]`

Always `[]`. The app writes nothing here, but carries whatever is present
through import and export unchanged.

---

## 5. Cross-references

All references stay within one plan and point at an asset `id`:

| From | Field | To |
|---|---|---|
| backup | `assetId` | `assets[].id` (one backup per asset) |
| dependency | `upstreamAssetId`, `downstreamAssetId` | `assets[].id` (two different assets) |
| dismissed suggestion | both halves of `"up>down"` | `assets[].id` |
| workflow | `assetIds[]` | `assets[].id` |
| scenario | `initiallyAffectedAssetIds[]` | `assets[].id` |

Nothing is checked on import. A reference to an asset that does not exist:

- shows as **"(deleted)"** in the dependency table and the scenario's Impact
  step;
- is dropped from a workflow's list of linked assets;
- still counts in the single-point-of-failure total. A dependency from a real
  asset to a missing one makes that real asset a single point of failure.

A backup record whose `assetId` matches no asset is kept and is not shown
with any asset, but it still counts toward the untested-backup issue on the
Dashboard (§6.2).

---

## 6. How the app reads a plan

Everything in this section is **computed every time the plan is shown**, and
none of it is stored. There is nothing to write for it; get the stored fields
right and these follow.

### 6.1 Backup state

Each asset's backup record is classified, in this order:

| Condition | State shown |
|---|---|
| No record, or `hasBackup` is `"Unknown"` | **Unknown** |
| `hasBackup` is `"No"` | **No backup** |
| `"Yes"` and `lastTestRestoreDate` is `""` | **Untested** |
| `"Yes"` and the test restore is more than **180 days** before today | **Stale test** |
| `"Yes"` and the test restore is within 180 days | **Verified** |

"Today" is the day the plan is opened, so a Verified backup turns Stale by
itself as time passes. That is intended.

### 6.2 The Dashboard

- **Backup coverage** is the share of assets whose backup says `"Yes"`,
  whatever its test state.
- **Issues to address** counts:
  - one for **each** `Critical` asset whose backup is not `"Yes"`;
  - one if **any** backup record is Untested or Stale;
  - one if there is **any** single point of failure.

### 6.3 Dependencies and single points of failure

- A failure travels downstream along **Hard and Soft** links. **Optional**
  links do not carry it. Any `dependencyType` other than `"Optional"`,
  including a misspelling, is treated as carrying it.
- An asset is a **single point of failure** when its failure reaches at least
  one other asset.
- The same rule drives a scenario's Impact step: the assets in
  `initiallyAffectedAssetIds`, then everything downstream of them.

### 6.4 Plan readiness

The Dashboard opens with five steps. Each is done when:

| Step | Done when |
|---|---|
| List your technology | there is at least one asset |
| Map what depends on what | there is at least one dependency **and** no suggestion is waiting for review (§4.4) |
| Say where the backups are | every `Critical` asset (or every asset, if none is `Critical`) has a backup record whose `hasBackup` is `"Yes"` or `"No"` |
| Write down the work that matters | there is at least one workflow |
| Rehearse one disaster | at least one scenario is `"Completed"` |

When all five are done the card reads **Plan complete**.

### 6.5 Dates

- Dates are exactly `YYYY-MM-DD`, such as `"2026-09-01"`. The date fields in
  the editor show anything else as empty.
- Dates are whole days, with no time and no time zone.

### 6.6 People

There is no people table. A person is a name typed into `ownerName` on an
asset or `responsiblePerson` on a recovery step. The app offers every such
name as an autocomplete suggestion, so:

- spell each person's name the same way everywhere;
- a combined value such as `"Luis Ferreira + IT vendor"` is kept as written
  and offered as its own suggestion.

An asset with no `ownerName` is reported as a gap ("No owner assigned") in
any exercise that reaches it.

---

## 7. Editing an existing export

- **Keep every record `id`**, and every reference to it. If you change an
  asset's id, change it in `backupInfos`, `dependencies`, `dismissedDeps`,
  `workflows` and `scenarios` too.
- **Keep `createdAt` as it is.** Set `updatedAt` to the current time in
  milliseconds on a record you changed, if you like; nothing checks it.
- **Keep extra keys** you do not recognize on records. The app keeps them.
- **The plan id does not matter**: it is replaced on import.
- **Nothing is hashed or signed.** No field has to be recomputed after an
  edit.
- **Do not write anything from §6.** Backup states, issue counts, single
  points of failure, readiness and suggestions are always recomputed.
- **Adding records:** follow §4 exactly, give each a new unique id, and for a
  new asset add its backup record too.
- **Deleting an asset:** also delete its backup record, every dependency that
  names it, and its id from every workflow's `assetIds` and scenario's
  `initiallyAffectedAssetIds`. Otherwise it shows as "(deleted)" (§5).
- **Recovery steps:** after adding, removing or reordering, renumber
  `stepNumber` to `1, 2, 3 …` in array order.
- **Importing the edited file adds a new workspace** beside the original. The
  user closes the old one when satisfied. Tell them so.
- **If you were given a full backup**, see §3.2: hand back a single-plan pack
  for the plan you changed, not the whole backup.

Re-exporting an imported plan with **Save workspace** gives back the same
file, apart from `puma.exportedAt`, `puma.appVersion` and `data.plan.id`.

---

## 8. Things that go wrong

| Mistake | What happens |
|---|---|
| No envelope: the plan's registers at the top level | Rejected: *"Unrecognized file format"*. |
| `data` present but no `puma` | Rejected: *"Unrecognized file format"*. |
| Not valid JSON | Rejected: *"That doesn’t look like a JSON or .pumapack backup"*. |
| `puma.app` set to another app's name | The user is asked whether to import anyway. Cancel does nothing, silently. |
| `data.plans` without `"kind": "full-backup"` | One **empty** plan is imported. |
| A register missing or `null` | Stored as `[]`. The data you meant to send is not there. |
| A register that is a string or object, e.g. `"assets": "…"` | *"Import failed: …"*, but the broken plan **is still saved** and opened, and the app shows a blank page on every load after that until site data is cleared. |
| A `null` element inside an array, e.g. `"assets": [ …, null ]` | The same: *"Import failed: …"* and a saved plan that cannot be shown. |
| An asset with no `name`, or `"name": null` | Imports, but opening the Dependencies view fails. |
| Enum in the wrong case, e.g. `"criticality": "critical"` | Kept. The asset no longer counts as Critical anywhere, and the dropdown shows blank. |
| A dependency pointing at a missing asset | "(deleted)" in the table; the upstream asset becomes a single point of failure. |
| Two backup records for one asset | The Dashboard uses the last one and the asset editor shows the first. They disagree. |
| `hasBackup: "Yes"` with no `lastTestRestoreDate` | Imports; shown as **Untested** and counted as an issue. Intended, but give a date if a restore was tested. |
| Timestamps as ISO strings | Kept. Write numbers so every export that reads them behaves the same. |
| `stepNumber` out of step with array order | Steps show in array order; the PumaTracker handoff numbers them by `stepNumber`. They disagree. |
| An extra key under `data` or `data.plan` | Silently dropped. |
| An extra key on a record | Kept, with no effect. |

---

## 9. Checklist before handing a pack over

A pack that passes all of these imports with no error and nothing re-stamped.

**Structure**
- [ ] The envelope matches §2: a `puma` object with `"app": "pumabcp"`, and a
      `data` object.
- [ ] `data.plan.name` is set.
- [ ] All seven registers are arrays, with no `null` elements.
- [ ] Every record has every field from §4. No `null` values.
- [ ] Ids are unique within each array.

**References**
- [ ] Every `assetId`, `upstreamAssetId`, `downstreamAssetId`, `assetIds`
      entry, `initiallyAffectedAssetIds` entry and `dismissedDeps` half
      matches an asset id (§5).
- [ ] No dependency links an asset to itself, and no pair appears twice in
      the same direction.
- [ ] At most one backup record per asset.

**Values**
- [ ] Every enum value is one of the exact spellings in §4.
- [ ] Every backup's `backupMethod` matches its `hasBackup` (§4.2).
- [ ] Every scenario's `recoverySteps` are numbered `1, 2, 3 …`.
- [ ] Dates are `YYYY-MM-DD`; timestamps are numbers in milliseconds.
- [ ] Each person's name is spelled the same everywhere.

---

## 10. A complete example

A small café-bakery plan: five assets with every backup state, five
dependencies including an Optional one, two dismissed suggestions, two
workflows, one completed scenario with recovery steps and one not yet
started. It imports with *"Imported 5 assets, 5 dependencies, 2 scenarios"*
and no warning.

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumabcp",
    "appVersion": "generated",
    "format": 1,
    "exportedAt": "2026-09-28T09:00:00.000Z",
    "title": "Harbor Street Bakery continuity plan"
  },
  "data": {
    "plan": {
      "id": "plan-harbor-street",
      "name": "Harbor Street Bakery continuity plan",
      "businessName": "Harbor Street Bakery"
    },
    "assets": [
      { "id": "ast-internet", "name": "Internet Connection", "category": "Network", "criticality": "Critical",
        "vendor": "Bayline Fiber", "vendorSupportUrl": "https://bayline.example/support", "vendorSupportPhone": "1-800-555-0142",
        "ownerName": "Dana Okafor", "ownerEmail": "dana@harborstreet.example", "ownerPhone": "555-0101",
        "notes": "Business fiber, account 44-1178. Static IP is used by nothing we know of.",
        "createdAt": 1790582400000, "updatedAt": 1790582400000 },
      { "id": "ast-router", "name": "Wi-Fi Router", "category": "Network", "criticality": "High",
        "vendor": "Bayline Fiber", "vendorSupportUrl": "", "vendorSupportPhone": "1-800-555-0142",
        "ownerName": "Dana Okafor", "ownerEmail": "dana@harborstreet.example", "ownerPhone": "555-0101",
        "notes": "", "createdAt": 1790582400000, "updatedAt": 1790582400000 },
      { "id": "ast-square", "name": "Square", "category": "SaaS", "criticality": "Critical",
        "vendor": "Block", "vendorSupportUrl": "https://squareup.com/help", "vendorSupportPhone": "1-855-700-6000",
        "ownerName": "Dana Okafor", "ownerEmail": "dana@harborstreet.example", "ownerPhone": "555-0101",
        "notes": "Card payments and the item library.", "createdAt": 1790582400000, "updatedAt": 1790582400000 },
      { "id": "ast-pos", "name": "Square POS", "category": "Hardware", "criticality": "Critical",
        "vendor": "Block", "vendorSupportUrl": "https://squareup.com/help", "vendorSupportPhone": "1-855-700-6000",
        "ownerName": "Luis Ferreira", "ownerEmail": "luis@harborstreet.example", "ownerPhone": "555-0102",
        "notes": "Two registers on the front counter.", "createdAt": 1790582400000, "updatedAt": 1790582400000 },
      { "id": "ast-books", "name": "QuickBooks Online", "category": "SaaS", "criticality": "High",
        "vendor": "Intuit", "vendorSupportUrl": "https://quickbooks.intuit.com/support", "vendorSupportPhone": "1-800-446-8848",
        "ownerName": "Priya Nair", "ownerEmail": "priya@harborstreet.example", "ownerPhone": "555-0103",
        "notes": "", "createdAt": 1790582400000, "updatedAt": 1790582400000 }
    ],
    "dependencies": [
      { "id": "dep-1", "upstreamAssetId": "ast-internet", "downstreamAssetId": "ast-square", "dependencyType": "Hard",
        "description": "Cloud services are unreachable without the line.", "createdAt": 1790582400000 },
      { "id": "dep-2", "upstreamAssetId": "ast-internet", "downstreamAssetId": "ast-router", "dependencyType": "Soft",
        "description": "The router keeps serving the LAN, but nothing beyond it.", "createdAt": 1790582400000 },
      { "id": "dep-3", "upstreamAssetId": "ast-router", "downstreamAssetId": "ast-pos", "dependencyType": "Hard",
        "description": "The registers reach Square over Wi-Fi.", "createdAt": 1790582400000 },
      { "id": "dep-4", "upstreamAssetId": "ast-square", "downstreamAssetId": "ast-pos", "dependencyType": "Hard",
        "description": "The registers run on the Square account.", "createdAt": 1790582400000 },
      { "id": "dep-5", "upstreamAssetId": "ast-square", "downstreamAssetId": "ast-books", "dependencyType": "Optional",
        "description": "Daily sales sync; can be re-run later.", "createdAt": 1790582400000 }
    ],
    "dismissedDeps": [
      "ast-internet>ast-pos",
      "ast-internet>ast-books"
    ],
    "backupInfos": [
      { "id": "bak-ast-internet", "assetId": "ast-internet", "hasBackup": "Yes", "backupMethod": "Self-managed",
        "backupLocation": "Phone hotspot in the office drawer", "backupFrequency": "Always on standby",
        "lastVerifiedDate": "2026-09-01", "lastTestRestoreDate": "2026-09-01",
        "restoreSteps": "1. Turn on the hotspot\n2. Join both registers to the hotspot network",
        "estimatedRestoreTime": "10 minutes" },
      { "id": "bak-ast-square", "assetId": "ast-square", "hasBackup": "Yes", "backupMethod": "Vendor-managed",
        "backupLocation": "Square's own infrastructure", "backupFrequency": "Continuous",
        "lastVerifiedDate": "2026-08-15", "lastTestRestoreDate": "",
        "restoreSteps": "", "estimatedRestoreTime": "" },
      { "id": "bak-ast-pos", "assetId": "ast-pos", "hasBackup": "No", "backupMethod": "None",
        "backupLocation": "", "backupFrequency": "", "lastVerifiedDate": "", "lastTestRestoreDate": "",
        "restoreSteps": "", "estimatedRestoreTime": "" },
      { "id": "bak-ast-books", "assetId": "ast-books", "hasBackup": "Unknown", "backupMethod": "Unknown",
        "backupLocation": "", "backupFrequency": "", "lastVerifiedDate": "", "lastTestRestoreDate": "",
        "restoreSteps": "", "estimatedRestoreTime": "" }
    ],
    "workflows": [
      { "id": "wf-sales", "name": "Take payment at the counter",
        "description": "Ring up orders and take card or cash at the front counter.",
        "criticality": "Critical", "maxTolerableDowntime": "30 minutes",
        "revenueImpactDescription": "About $400 an hour in lost sales on a weekday morning",
        "manualWorkaround": "Cash only, with a handwritten receipt book. Enter the sales into Square once it is back.",
        "assetIds": ["ast-square", "ast-pos", "ast-router", "ast-internet"],
        "createdAt": 1790582400000, "updatedAt": 1790582400000 },
      { "id": "wf-close", "name": "Month-end close",
        "description": "Reconcile sales, pay suppliers and run payroll reports.",
        "criticality": "Medium", "maxTolerableDowntime": "3 days",
        "revenueImpactDescription": "",
        "manualWorkaround": "Pull the Square sales reports by hand and reconcile in a spreadsheet.",
        "assetIds": ["ast-books", "ast-square"],
        "createdAt": 1790582400000, "updatedAt": 1790582400000 }
    ],
    "scenarios": [
      { "id": "scn-internet", "name": "Internet down on a Saturday morning", "scenarioType": "Service Outage",
        "triggerDescription": "At 7:30 on a Saturday the fiber line drops. The registers show offline and there is a queue out the door.",
        "initiallyAffectedAssetIds": ["ast-internet"],
        "discussionPrompts": [
          "How long can the registers keep taking cards offline?",
          "Who decides when to switch to cash only?"
        ],
        "recoverySteps": [
          { "id": "rs-1", "stepNumber": 1, "action": "Switch both registers to the phone hotspot",
            "responsiblePerson": "Luis Ferreira", "estimatedDuration": "10 minutes", "notes": "" },
          { "id": "rs-2", "stepNumber": 2, "action": "Call Bayline and log a fault",
            "responsiblePerson": "Dana Okafor", "estimatedDuration": "15 minutes", "notes": "Account 44-1178" }
        ],
        "exerciseNotes": "Walked through on 12 September.\n\n- [x] Hotspot moved to the office drawer\n- [ ] Label the hotspot password",
        "status": "Completed",
        "createdAt": 1790582400000, "updatedAt": 1790582400000 },
      { "id": "scn-owner", "name": "Owner unreachable for two weeks", "scenarioType": "Key Person Loss",
        "triggerDescription": "Dana is travelling with no signal. The Square account needs a password reset.",
        "initiallyAffectedAssetIds": [],
        "discussionPrompts": ["Who else can sign in to Square and QuickBooks?"],
        "recoverySteps": [],
        "exerciseNotes": "",
        "status": "Not Started",
        "createdAt": 1790582400000, "updatedAt": 1790582400000 }
    ],
    "history": []
  }
}
```

What the app shows for this plan, as a check on your own reasoning. These
figures were read from the app after importing this exact file (the backup
states assume it is opened within 180 days of 1 September 2026):

- Every record is stored exactly as written. Saving the workspace again gives
  back this file apart from `exportedAt`, `appVersion` and the plan id.
- Backup states: Internet Connection **Verified**, Square **Untested**,
  Square POS **No backup**, QuickBooks Online **Unknown**, and Wi-Fi Router
  **Unknown** (it has no backup record).
- **Backup coverage 40%**: 2 of 5 assets back up.
- **3 critical** assets, **5 dependencies**, **3 single points of failure**:
  Internet Connection, Wi-Fi Router and Square. QuickBooks Online is not hit
  by anything, because its only link is Optional.
- **3 issues to address**: Square POS is Critical with no backup; a backup is
  untested; there are single points of failure.
- **Plan readiness** reads **Plan complete**. The two suggestions the app
  would otherwise make (Internet Connection to Square POS and to QuickBooks
  Online) are dismissed, the three Critical assets all have a stated backup,
  and one scenario is Completed.
- **Scenarios 1/2** completed.
