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

This document describes PumaTTX's `.pumapack` files in enough detail to
**edit an export** or **generate one from scratch** so that it imports
cleanly: no error, nothing dropped, and nothing filled in behind your back.
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. PumaTTX imports it in either of two ways:

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

Import always **adds**. It never replaces or merges into a program or
exercise the user already has. Re-importing an edited export therefore gives
the user a second copy next to the original, which they close by hand.

PumaTTX writes three kinds of pack. Two of them share one shape:

| Pack | Where the user gets it | Shape | Imports as |
|---|---|---|---|
| **Backup** | Topbar **Export**, or ⌘/Ctrl+S | `data.programs` (every program) plus `prefs` | New programs |
| **Program** | Right-click a program tab → **Export program…** | `data.programs` (one program) | A new program |
| **Exercise** | An exercise's **⋯** menu → **Export exercise…** | `data.exercises` (one exercise) | An exercise added to the program that is open |

The backup is what a user most naturally exports and hands to an AI, and it
is the shape this document leads with. The exercise pack is covered in §2.3.

PumaTTX also writes two hand-off files for other apps (findings for PumaRisk,
improvement actions for PumaTracker). PumaTTX refuses to import those; they
are not covered here.

---

## 1. The short version

If you only read one section, read this one.

1. Wrap your data in the envelope from §2. The importer insists on
   `puma.app` being `"pumattx"` and reads only `data.programs` (or
   `data.exercises` for an exercise pack).
2. Write **every field** of every record, using the shapes in §4. Use `""`,
   `[]`, `false` or `0` for "nothing". **Never put `null` inside an array**:
   a `null` record aborts the import or breaks a page. The one field where
   `null` is normal is an objective's `result`.
3. **Always write `planningChecklist`, `groundRules` and `brief`.** If one is
   missing, the app fills in its own default text (a 21-item checklist, four
   ground rules, a stock pre-read).
4. Give every record an `id` that is unique within the exercise (or program,
   for stakeholders and program variables). Short readable ids (`obj-1`,
   `inj-2`) are fine.
5. Number injects and findings by position: `num` is `1`, `2`, `3` in array
   order. Number improvement actions `TTX-01`, `TTX-02` in array order.
   See §6.2.
6. Every id reference must point at a record that exists. The reference
   fields are `participants`, `objectivesTested` and `findingId`. See §5.
7. Every `[TOKEN]` in read-aloud text needs a variable with that exact key
   and a non-empty value, either on the program or on the exercise. See §6.1.
8. Use the exact enum values in §4. Nothing is validated on import; a typo is
   kept and shows as blank or unlabeled.
9. Check the result against the checklist in §9.

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

---

## 2. The envelope

### 2.1 Backup and program packs

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumattx",
    "appVersion": "generated",
    "format": 1,
    "exportedAt": "2026-09-22T09:00:00.000Z",
    "kind": "backup",
    "title": "PumaTTX backup"
  },
  "data": {
    "programs": [ { "...one program object, see §3..." } ],
    "activeSlug": "northwind-health-ttx-program",
    "prefs": { "theme": null, "accent": null }
  }
}
```

| Key | Value | Notes |
|---|---|---|
| `$schema` | `"https://pumaworx.dev/pumapack/v1"` | Not checked. Write it anyway. |
| `puma.app` | `"pumattx"` | **Required, exactly.** |
| `puma.appVersion` | any string | Informational. The app writes its build id, or `"dev"`. |
| `puma.format` | `1` | The schema number. Not checked on import, but write `1`. |
| `puma.exportedAt` | ISO 8601 datetime | Informational. |
| `puma.kind` | `"backup"` or `"program"` | Informational for these two. `"exercise"` switches to the §2.3 shape. |
| `puma.title` | string | Informational. The app writes `"PumaTTX backup"` or `"PumaTTX program · <name>"`. |
| `data.programs` | array of program objects | **Must be non-empty.** |
| `data.activeSlug` | string | **Ignored** on import. |
| `data.prefs` | object | Backup only. See below. Omit it, or write both values as `null`. |

`data.prefs` carries the exporting user's display settings:

- `theme` is `"light"`, `"dark"` or `null`. `"light"` or `"dark"`
  **switches the importing user's theme**.
- `accent` is a `#rrggbb` color or `null`. Any non-empty value **replaces the
  importing user's own accent color**.

A generated pack should leave both `null` so it does not change the reader's
settings.

### 2.2 What the importer requires

In order, the importer checks:

| Check | If it fails, the user sees |
|---|---|
| The file is valid JSON | *"That file isn’t a recognised .pumapack / JSON backup."* |
| It has a `puma` object | *"Not a .pumapack file — missing the puma envelope."* |
| `puma.app` is `"pumattx"` | *"That pack is from pumaplanner. Open it in that app instead."* (naming whatever app the file says) |
| It is not a PumaRisk or PumaTracker hand-off | A warning naming the app to import it in |
| `data.programs` (or, for an exercise pack, `data.exercises`) is a non-empty array | *"Import failed — no programs or exercises in this pack."* |

A bare program object, or a `data` object with no `puma` envelope, is
**rejected**. Every other envelope key is ignored.

On success the user sees **"Imported 1 program"** (or "Imported 3 programs").

What happens to each imported program:

- It is **added** after the user's existing programs, and the last one in
  the file becomes the open tab.
- Its **`id` is replaced** with a new one.
- Its **`slug` is recomputed from `name`** (see §3). The file's `slug` is
  ignored.
- The **last** program in the file gets `updated_at` set to the moment of
  import. Any earlier programs in the same file keep theirs.
- Everything inside it (exercises, records, their ids and timestamps) is kept
  as written, apart from the defaults described in §4.

### 2.3 Exercise packs

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumattx", "appVersion": "generated", "format": 1,
    "exportedAt": "2026-09-22T09:00:00.000Z",
    "kind": "exercise", "title": "PumaTTX exercise · Q4 ransomware tabletop"
  },
  "data": {
    "programName": "Northwind Health TTX program",
    "exercises": [ { "...one exercise object, see §4.3..." } ]
  }
}
```

- `puma.kind` must be `"exercise"` **and** `data.exercises` must be a
  non-empty array. Otherwise the file is treated as a program pack.
- `programName` is informational and ignored.
- Each exercise is added to the **top** of the program the user has open.
- Its **`id` is replaced**, and **`" (imported)"` is appended to its
  `name`**. Nothing else in it changes.
- The success message is **"Imported 1 exercise into “&lt;program name&gt;”"**.
- Stakeholders are **not** part of an exercise pack. The exercise's
  `participants` hold stakeholder ids from the program it came from; in a
  different program they point at nothing (§5).

Use an exercise pack to add one exercise to an existing program. Use a
program pack for anything that needs stakeholders or program variables.

---

## 3. The program object

A program is one workspace tab: an organization's tabletop program, with a
reusable roster and placeholder values, and any number of exercises.

```json
{
  "id": "prog-northwind",
  "slug": "northwind-health-ttx-program",
  "name": "Northwind Health TTX program",
  "accent_color": "#5ecc94",
  "created_at": "2026-08-03T09:00:00.000Z",
  "updated_at": "2026-09-22T09:00:00.000Z",
  "org": {
    "variables":    [ ],
    "stakeholders": [ ]
  },
  "exercises": [ ]
}
```

| Field | Type | Notes |
|---|---|---|
| `id` | string | Replaced on import. Write any string. |
| `slug` | string | Replaced on import by `name` lower-cased, with each run of characters other than `a-z0-9` turned into `-`, trimmed of leading and trailing `-`, cut to 40 characters. If the user already has a program with that slug, `-2`, `-3` and so on is added. Write that value so a round trip is exact. |
| `name` | string | Shown on the tab. **Must be a non-empty string.** |
| `accent_color` | `#rrggbb` | The tab's dot color. The app's palette is `#a87fe0` (its default), `#5ecc94`, `#d4a464`, `#e05050`, `#5b8af0`, `#4ec9b0`, `#e06090`, `#c8b830`. Any other six-digit hex also works; anything else is kept but drawn as `#5b8af0`. |
| `created_at`, `updated_at` | ISO 8601 datetime | Full timestamps, not bare dates. |
| `org.variables` | array | Program-wide placeholder values. See §4.1. |
| `org.stakeholders` | array | The roster of people. See §4.2. |
| `exercises` | array | See §4.3. **Array order is display order.** |

A missing or non-array `org.variables`, `org.stakeholders` or `exercises`
becomes `[]`. **Unknown keys are kept** at every level, and exported again,
but have no effect.

---

## 4. Record shapes

### Conventions for every record

- **`id`** is any non-empty string, unique within its own array.
  - The app generates ids like `inj-mulx9b6baeaox`. Short readable ids work
    just as well.
  - The app never changes a record's id on import (only a program's, and an
    exercise's in an exercise pack), so references between records survive.
- **Dates** are `"YYYY-MM-DD"`. **Datetimes** are full ISO 8601 strings.
- **Defaults are filled in only when a key is missing** (or, for most
  arrays and objects, is `null` or not the right type). A missing text field
  is simply blank. The fields that get **visible default text** are listed
  in §4.3.
- **Enum values are not validated on import.** A misspelled value is kept,
  and a dropdown then shows its first option or nothing. Use the exact
  values below.
- **"One per line" lists** (assumptions, ground rules, questions, hotwash
  notes, strengths) are **arrays of strings**, one item per string. The app
  edits them as one line each, so a string containing a line break becomes
  two items the next time the user edits that list.

### 4.1 Variables: `org.variables[]` and an exercise's `variables[]`

```json
{ "id": "var-company", "key": "COMPANY", "value": "Northwind Health", "label": "Organization name" }
```

| Field | Type | Meaning |
|---|---|---|
| `id` | string | Write one. A variable without an id gets one the first time its table is drawn, and it is saved on the next edit. |
| `key` | string | The token name, **without brackets**: `"COMPANY"` fills `[COMPANY]`. Case-sensitive. At most 40 characters. A variable with an empty key is ignored. |
| `value` | string | The replacement text. An empty value leaves the token unresolved. |
| `label` | string | A note for the user; shown as "Note". |

Program variables are defaults for every exercise in the program. An
exercise variable with the same `key` overrides the program's for that
exercise. See §6.1.

### 4.2 `org.stakeholders[]`

```json
{
  "id": "sh-priya", "name": "Priya Shah", "function": "Executive",
  "email": "priya.shah@example.org", "phone": "+1 555 0102",
  "decisionMaker": true, "notes": ""
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | string | Referenced by an exercise's `participants`. |
| `name` | string | Full name. |
| `function` | enum | One of `"Security / IR"`, `"IT / Infrastructure"`, `"Legal / Privacy"`, `"Communications"`, `"Executive"`, `"HR"`, `"Business / Ops"`, `"Finance"`, `"SME / Other"`. |
| `email`, `phone` | string | Contact details. `""` if unknown. |
| `decisionMaker` | boolean | Can this person actually make the call? Marked "decides" on the participant picker, and counted in the program's "With decision-makers" measure. |
| `notes` | string | Kept and exported. Not currently shown in the app. |

### 4.3 `exercises[]`

An exercise holds its whole lifecycle in one object: Plan, Run and Learn.

```json
{
  "id": "ex-ransom-q4",
  "name": "Q4 ransomware tabletop",
  "status": "closed",
  "created_at": "2026-08-03T09:00:00.000Z",
  "updated_at": "2026-09-22T09:00:00.000Z",
  "date": "2026-09-15",
  "startTime": "09:30",
  "durationMin": 90,
  "location": "Board room, 3rd floor",
  "format": "In-person",
  "difficulty": "Moderate",
  "variables": [ ],
  "objectives": [ ],
  "scope": { "text": "", "worldwide": false },
  "assumptions": [ ],
  "groundRules": [ ],
  "scenario": { },
  "participants": [ ],
  "exerciseRoles": { "facilitator": "", "scribe": "", "evaluator": "", "sme": "" },
  "planningChecklist": [ ],
  "brief": { "purpose": "", "whatToExpect": "", "whatToBring": "" },
  "injects": [ ],
  "ros": { "welcomeMin": 10, "setupMin": 10, "hotwashMin": 20 },
  "hotwash": { "reactions": [], "whatWorked": [], "whatHard": [], "topImprovements": [], "parkingLot": [] },
  "findings": [ ],
  "improvementPlan": [ ],
  "captures": [ ],
  "aar": { "execSummary": "", "strengths": [], "frameworkMapping": "", "handoff": { "keeper": "", "risk": "" } }
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | string | Unique within the program. |
| `name` | string | Shown on the exercise card and every handout. |
| `status` | enum | `"draft"`, `"ready"`, `"run"` or `"closed"`; shown as Draft, Ready, Run, Closed. The program view counts `closed` and `run`. |
| `created_at`, `updated_at` | ISO 8601 datetime | The card shows "Updated &lt;date&gt;". |
| `date` | `"YYYY-MM-DD"` or `""` | The day it runs. |
| `startTime` | `"HH:MM"`, 24-hour | Wall-clock start. The run-of-show clock counts from it. Write two-digit hours (`"09:30"`). Anything that is not `H:MM` or `HH:MM` makes the clock start at 9:00. |
| `durationMin` | whole number ≥ 0 | Planned length in minutes. If the run-of-show adds up to more (§6.3), the Run-of-show step warns. |
| `location` | string | Room or bridge link. |
| `format` | enum | `"In-person"`, `"Remote"` or `"Hybrid"`. |
| `difficulty` | enum | `"Intro"`, `"Moderate"` or `"Advanced"`. |
| `variables` | array | Per-exercise overrides; see §4.1. |
| `objectives` | array | See §4.4. |
| `scope` | object | See §4.5. |
| `assumptions` | array of strings | One assumption per string. |
| `groundRules` | array of strings | One rule per string. **Missing → four default rules are added.** Write `[]` for none. |
| `scenario` | object | See §4.6. |
| `participants` | array of stakeholder ids | See §4.7. |
| `exerciseRoles` | object | See §4.7. |
| `planningChecklist` | array | See §4.8. **Missing → the app's 21-item default checklist is added.** Write `[]` for none. |
| `brief` | object | See §4.9. **A missing `whatToExpect` or `whatToBring` gets stock text.** |
| `injects` | array | See §4.10. **Array order is running order.** |
| `ros` | object | See §4.11. |
| `hotwash` | object | See §4.12. |
| `findings` | array | See §4.13. |
| `improvementPlan` | array | See §4.14. |
| `captures` | array | See §4.15. |
| `aar` | object | See §4.16. **A missing `frameworkMapping` gets `"NIST SP 800-61 / CSF 2.0 · HSEEP"`.** |

Missing or `null` arrays become `[]`. Missing or `null` objects get their
default fields, and a partial object has only its missing keys filled in.
A missing `status` becomes `"draft"`, `startTime` `"09:00"`, `durationMin`
`120`, `format` `"In-person"` and `difficulty` `"Moderate"`.

### 4.4 `objectives[]`

```json
{ "id": "obj-1", "text": "Determine who can authorize taking [EHR] offline, and how fast they can be reached.",
  "result": "partial", "notes": "The COO was named, but no backup when she is unreachable." }
```

| Field | Values |
|---|---|
| `text` | One specific, observable outcome. May contain `[TOKENS]`. |
| `result` | `null` (not yet judged), `"met"`, `"partial"` or `"not"` (shown as Not met). `""` also means not judged. |
| `notes` | What was observed. |

Array order is the objective's number (#1, #2, ...). Three to five is the
app's advice.

### 4.5 `scope`, `assumptions`, `groundRules`

```json
"scope": { "text": "Clinical and corporate IT at the main campus.", "worldwide": false },
"assumptions": ["Detection tooling worked and produced the alerts described."],
"groundRules": ["No-fault: nothing said here is used in performance reviews."]
```

- `scope.text` is free text. `scope.worldwide` is a boolean: a global,
  cross-border scenario.
- `assumptions` and `groundRules` are arrays of strings.

### 4.6 `scenario`

```json
{
  "threatType": "Ransomware / extortion",
  "triggerEvent": "", "orgContext": "", "escalationArc": "", "resolution": "",
  "background": "", "facilitatorSetupNotes": ""
}
```

| Field | Meaning |
|---|---|
| `threatType` | `""` or one of `"Ransomware / extortion"`, `"Business email compromise"`, `"Malicious insider"`, `"Targeted intrusion / espionage"`, `"Third-party / supply chain"`, `"Data breach / leak"`, `"DDoS / availability"`, `"Other"`. Shown on the exercise card and counted in the program's scenario coverage. |
| `triggerEvent` | The first sign something is wrong. |
| `orgContext` | The real systems, units and regulators in play. |
| `escalationArc` | How pressure rises across the injects. |
| `resolution` | How the incident ends. |
| `background` | **Read aloud** to open the exercise. May contain `[TOKENS]`. |
| `facilitatorSetupNotes` | Private to the facilitator; never read aloud. |

### 4.7 `participants` and `exerciseRoles`

```json
"participants": ["sh-ana", "sh-priya", "sh-tom"],
"exerciseRoles": { "facilitator": "Ana Costa", "scribe": "Sam Lee", "evaluator": "", "sme": "" }
```

- `participants` is an array of **stakeholder ids** from the same program's
  `org.stakeholders`. It is not a list of names.
- `exerciseRoles` values are **free-text names**, not ids. They need not be
  on the roster. `facilitator` and `scribe` should be different people;
  `evaluator` and `sme` are optional.

### 4.8 `planningChecklist[]`

```json
{ "id": "ck-1", "phase": "4wk", "text": "Define 3 to 5 specific, observable objectives.", "done": true }
```

| Field | Values |
|---|---|
| `phase` | `"4wk"` (3–4 weeks before), `"2wk"` (2 weeks before), `"1wk"` (1 week before), `"dayof"` (Day of) or `"after"` (After). **An item with any other phase is not shown.** |
| `text` | The to-do. |
| `done` | boolean. |

Items are shown grouped by phase, in array order within each phase.

### 4.9 `brief`

```json
{ "purpose": "", "whatToExpect": "", "whatToBring": "" }
```

The participant pre-read. `purpose` is one or two sentences on why people
are there. It must never give away the scenario.

### 4.10 `injects[]`

```json
{
  "id": "inj-1", "num": 1, "title": "Files will not open", "timeboxMin": 20,
  "primaryFunctions": ["Security / IR", "IT / Infrastructure"],
  "objectivesTested": ["obj-1"],
  "readAloud": "At 09:40 the help desk has twelve tickets...",
  "openingQuestion": "Walk me through your first fifteen minutes.",
  "probingQuestions": ["Who decides whether to take [EHR] offline?"],
  "whatGoodLooksLike": ["A named incident lead within 15 minutes."],
  "likelyGaps": ["No backup approver for a clinical shutdown."],
  "facilitatorNotes": "",
  "isCurveball": false
}
```

| Field | Type | Meaning |
|---|---|---|
| `num` | integer | Its 1-based position in `injects`. See §6.2. |
| `title` | string | Short name, shown in the run-of-show. |
| `timeboxMin` | whole number ≥ 0 | Minutes of discussion. Drives the run-of-show (§6.3). |
| `primaryFunctions` | array of strings | Which functions it targets. Use the `function` values from §4.2. |
| `objectivesTested` | array of objective ids | Which objectives this inject exercises. Must be an array; a string is replaced by `[]`, losing the links. |
| `readAloud` | string | The event as it would arrive, **read aloud**. May contain `[TOKENS]`. |
| `openingQuestion` | string | The first question to the room. |
| `probingQuestions` | array of strings | Follow-ups. |
| `whatGoodLooksLike` | array of strings | Answer key: signs of a healthy plan. Not read aloud. |
| `likelyGaps` | array of strings | Where you expect the team to struggle. Not read aloud. |
| `facilitatorNotes` | string | Private. |
| `isCurveball` | boolean | Held in reserve to break a comfortable consensus. Flagged in the run-of-show. |

A missing `id` is generated. Missing list fields become `[]`.

### 4.11 `ros`

```json
{ "welcomeMin": 10, "setupMin": 10, "hotwashMin": 20 }
```

The three fixed segments of the run-of-show, in whole minutes ≥ 0: welcome
and ground rules, scenario setup, and the hotwash debrief. A hotwash under 20
minutes turns the "protect the hotwash" note into a warning. The
run-of-show itself is **derived, never stored** (§6.3).

### 4.12 `hotwash`

```json
{ "reactions": [], "whatWorked": [], "whatHard": [], "topImprovements": [], "parkingLot": [] }
```

All five are arrays of strings, captured in the debrief: gut reactions, what
worked, what was hard, the room's top three improvements, and off-topic
points parked for later. `topImprovements` can be turned into improvement
actions with one click in the app.

### 4.13 `findings[]`

```json
{ "id": "find-1", "num": 1, "text": "No backup approver exists for taking [EHR] offline.",
  "impact": "H", "recommendation": "Name and train a backup approver." }
```

| Field | Values |
|---|---|
| `num` | Its 1-based position in `findings`. See §6.2. |
| `text` | What was observed. Describe behavior and process, never individuals. |
| `impact` | `"H"`, `"M"` or `"L"` (High, Medium, Low). |
| `recommendation` | The fix. |

### 4.14 `improvementPlan[]`

```json
{ "id": "act-1", "ttxId": "TTX-01", "action": "Name a backup approver for clinical shutdowns.",
  "owner": "Priya Shah", "priority": "H", "due": "2026-10-30", "status": "open", "findingId": "find-1" }
```

| Field | Values |
|---|---|
| `ttxId` | `"TTX-"` plus a number of at least two digits. See §6.2. Printed in the report and carried to PumaTracker. |
| `action` | The fix, as a task. |
| `owner` | One accountable person's name. Free text. |
| `priority` | `"H"`, `"M"` or `"L"`. |
| `due` | `"YYYY-MM-DD"` or `""`. |
| `status` | `"open"`, `"inprogress"` or `"done"`. Not editable in PumaTTX; it is carried to PumaTracker when the actions are sent there. |
| `findingId` | The id of the finding this action fixes, or `""`. A missing or `null` value becomes `""`. |

### 4.15 `captures[]`

```json
{ "id": "cap-1", "ts": "2026-09-15T13:52:00.000Z", "text": "Help desk escalated fast." }
```

Notes jotted live during the exercise. `ts` is a full ISO datetime, shown as
a local time of day. **Newest first**: the app adds new captures to the
front of the array.

### 4.16 `aar`

```json
{
  "execSummary": "", "strengths": [],
  "frameworkMapping": "NIST SP 800-61 / CSF 2.0 · HSEEP",
  "handoff": { "keeper": "", "risk": "" }
}
```

| Field | Meaning |
|---|---|
| `execSummary` | The After-Action Report's executive summary. |
| `strengths` | Array of strings: what to preserve. |
| `frameworkMapping` | Free text naming the frameworks the report maps to. |
| `handoff.keeper` | ISO datetime when the actions were last sent to PumaTracker, or `""`. The app sets it. |
| `handoff.risk` | ISO datetime or `""`. Shown as "Handed off to PumaRisk" when set. The app does not currently set it; write `""`. |

---

## 5. Cross-references

| From | Field | To |
|---|---|---|
| exercise | `participants[]` | `org.stakeholders[].id` in the **same program** |
| inject | `objectivesTested[]` | `objectives[].id` in the **same exercise** |
| improvement action | `findingId` | `findings[].id` in the **same exercise**, or `""` |
| any read-aloud text | `[KEY]` | `variables[].key` on the exercise or `org.variables[].key` on its program |

What a broken reference does:

- A `participants` id that matches no stakeholder is not shown on the
  Participants step, but it is still **counted** in the report's participant
  total.
- An `objectivesTested` id that matches no objective is ignored: the inject
  shows no link to it.
- A `findingId` that matches no finding shows as "no specific finding", and
  the action is **left out** of the PumaRisk hand-off, where a linked action
  rides along as its finding's treatment.

---

## 6. Things specific to PumaTTX

### 6.1 Placeholders (`[TOKENS]`)

Read-aloud text is written with placeholders so one scenario fits many
organizations: `"It is a Tuesday morning at [COMPANY]."`

- **What counts as a token:** `[`, then 1 to 40 characters with no `]` and
  no line break, then `]`. **Any** such bracketed text is treated as a token,
  so do not use square brackets for anything else in the text.
- **The key** is the text inside the brackets with surrounding spaces
  removed. It is matched **exactly and case-sensitively** against variable
  `key`s.
- **Resolution:** the program's `org.variables` first, then the exercise's
  `variables`, which win on a clash. A variable with an empty `value` does
  not resolve its token.
- **Unresolved tokens** stay bracketed and are highlighted on screen and in
  handouts. The exercise card counts them ("2 placeholders"), counting those
  in `scenario.background` and every inject's `readAloud`.
- Tokens are substituted in handouts and exports, not in the stored text.
  Keep the brackets in the data.

### 6.2 Numbering

The app shows injects, objectives and findings numbered by **position**.
Three stored numbers must agree with that:

| Field | Rule | What the app does |
|---|---|---|
| inject `num` | `1`, `2`, `3` in array order | A missing or non-positive `num` is set to the inject's position. **Any other value is kept**, even if wrong. Moving, adding or deleting an inject in the app renumbers them all. Other apps that read this file (PumaTimer) use `num` for their labels. |
| finding `num` | `1`, `2`, `3` in array order | Kept as written. The next finding added gets the highest `num` plus one. |
| action `ttxId` | `TTX-01`, `TTX-02` ... in array order | Kept as written. The next action gets the highest number plus one. Malformed ids are ignored for that count; **duplicates are not detected**. |

### 6.3 The run-of-show is derived

The minute-by-minute run-of-show is **computed, never stored**. It is:

1. Welcome and ground rules: `ros.welcomeMin`
2. Scenario setup: `ros.setupMin`
3. One segment per inject, in array order: its `timeboxMin`
4. Hotwash: `ros.hotwashMin`

The clock starts at `startTime` and each segment starts when the previous one
ends. The total is compared with `durationMin`. Values that are not numbers
count as 0. A negative number runs the clock backwards, so never write one.

So the injects' order and time boxes **are** the schedule. There is nothing
else to keep in sync.

### 6.4 Dates and times

- Dates (`date`, `due`) are exactly `YYYY-MM-DD`. The date fields cannot show
  any other format.
- `startTime` is `HH:MM`, 24-hour.
- `created_at`, `updated_at`, capture `ts` and `handoff.*` are full ISO 8601
  datetimes, e.g. `"2026-09-15T13:52:00.000Z"`.

---

## 7. Editing an existing export

The safest edit keeps the file's shape and changes values.

**Preserve:**

- **Every record `id`** inside an exercise, and every stakeholder id. The
  references in §5 depend on them, and the app keeps them on import.
- `created_at`, `updated_at` and capture `ts` values, unless the edit is
  meant to change them.
- **Unknown keys.** They survive import and export unchanged; leave them.
- The `puma` envelope. Change `puma.app` or remove `puma` and the file is
  rejected.

**Change freely:** any text, enum or number field, using the values in §4.

**When adding records:**

- Give each a new id not already used in that array.
- Append injects, objectives and findings where they belong, then renumber
  inject and finding `num` to match positions, and give a new action the next
  `ttxId`.
- A new inject that tests an objective lists that objective's id in
  `objectivesTested`.
- A new token in read-aloud text needs a matching variable (§6.1).

**When deleting records:** remove every reference to them. Deleting an
objective means removing its id from every inject's `objectivesTested`;
deleting a finding means setting `findingId` to `""` on any action that
pointed at it; deleting a stakeholder means removing its id from every
`participants`. Then renumber.

**What the app replaces on import, whatever the file says:**

| Field | Replaced by |
|---|---|
| program `id` | a new generated id |
| program `slug` | recomputed from `name` (§3) |
| `updated_at` of the last program in the file | the time of import |
| exercise `id` and `name` (exercise packs only) | a new id; `" (imported)"` appended to the name |

Everything else is kept exactly. In particular the app does **not**
renumber, re-date or reorder anything on import.

**Never hand-edit** `handoff.keeper`. It records when the app last sent the
actions to PumaTracker.

**Re-importing adds a copy.** The user ends up with the original program and
the edited one side by side (the edited one's tab slug gains `-2` if the name
is unchanged). They close the original by hand once they are happy.

---

## 8. Things that go wrong

| Mistake | What happens |
|---|---|
| Programs with no `puma` envelope | Rejected: *"Not a .pumapack file — missing the puma envelope."* |
| `puma.app` is anything but `"pumattx"` | Rejected: *"That pack is from &lt;app&gt;. Open it in that app instead."* |
| Programs under any key but `data.programs` | Rejected: *"Import failed — no programs or exercises in this pack."* |
| `null` as a record inside `injects` (or as a program) | The import stops part-way with **no message at all**. Do not rely on anything from that file having been saved. |
| `null` as a record inside `objectives`, `findings` or another list | It imports, but the page that lists those records fails to draw. |
| `null` or a non-array where a list goes | Replaced by `[]`. Anything that was meant to be there is gone. |
| `objectivesTested` as a string, e.g. `"obj-1"` | Replaced by `[]`; the link is lost. |
| No `planningChecklist`, `groundRules` or `brief` | The app's default checklist, ground rules or pre-read text is added. |
| Enum typo, e.g. `"Closed"` or `"in-progress"` | Kept as written. It shows as blank or raw, and falls out of the counts. |
| A checklist item with an unknown `phase` | Never shown. |
| Inject `num` out of step with position | Kept. Numbers disagree between PumaTTX and apps that read `num`. |
| A `[TOKEN]` with no variable, or a variable with an empty value | The token stays bracketed and highlighted; the card counts it as a placeholder. |
| Square brackets used for anything else in read-aloud text | Treated as an unresolved token. |
| Case differs between token and key (`[Company]` vs `COMPANY`) | Unresolved. |
| `participants` holding names instead of ids | Nobody is shown as participating, but the report still counts them. |
| An exercise pack whose exercise has `participants` | Imports, but those ids point at nothing in the new program. |
| `prefs.theme` / `prefs.accent` set in a generated backup | Changes the importing user's theme and accent color. |
| Editing an export and re-importing it | A second copy appears next to the original. It does not replace it. |
| A negative `timeboxMin` or `ros` value | The run-of-show clock runs backwards. |
| `startTime` like `"9:30am"` | The run-of-show starts at 9:00 and the time field is blank. |

---

## 9. Checklist before handing a pack over

A pack that passes all of these imports with no error, needs nothing filled
in, and reads identically after a round trip.

**Structure**
- [ ] The envelope matches §2: `puma.app` is `"pumattx"`, `puma.format` is
      `1`, and `data.programs` is a non-empty array (or, for an exercise
      pack, `puma.kind` is `"exercise"` and `data.exercises` is non-empty).
- [ ] `data.prefs` is absent or both values are `null`.
- [ ] Every record has every field from §4. No `null` anywhere except an
      objective's `result`.
- [ ] Every exercise has `planningChecklist`, `groundRules` and `brief`
      written out.
- [ ] Ids are unique within each array.

**References**
- [ ] Every `participants` entry is a stakeholder id in the same program.
- [ ] Every `objectivesTested` entry is an objective id in the same exercise.
- [ ] Every `findingId` is a finding id in the same exercise, or `""`.
- [ ] Every `[TOKEN]` in `scenario.background` and each `readAloud` has a
      variable with that exact key and a non-empty value.

**Numbering**
- [ ] Inject and finding `num` run `1, 2, 3...` in array order.
- [ ] `ttxId` runs `TTX-01, TTX-02...` in array order with no duplicates.

**Values**
- [ ] Every enum value is one of the exact values in §4.
- [ ] Every checklist `phase` is one of the five in §4.8.
- [ ] Dates are `YYYY-MM-DD`, `startTime` is `HH:MM`, and timestamps are full
      ISO datetimes.
- [ ] Minutes are whole numbers ≥ 0, and the run-of-show total (§6.3) is no
      more than `durationMin`.
- [ ] Program `slug` equals the value derived from `name` (§3).

---

## 10. A complete example

A backup holding one program: a three-person roster, two program variables,
and one closed exercise that has been run and debriefed. It has an
exercise-level variable override, two objectives, two injects (one a
curveball), a short checklist across all five phases, two findings, two
improvement actions (one linked to a finding) and a live capture. It imports
with no warnings, and the app stores it exactly as written apart from the
program `id` and `updated_at` (§7).

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumattx",
    "appVersion": "generated",
    "format": 1,
    "exportedAt": "2026-09-22T09:00:00.000Z",
    "kind": "backup",
    "title": "PumaTTX backup"
  },
  "data": {
    "programs": [
      {
        "id": "prog-northwind",
        "slug": "northwind-health-ttx-program",
        "name": "Northwind Health TTX program",
        "accent_color": "#5ecc94",
        "created_at": "2026-08-03T09:00:00.000Z",
        "updated_at": "2026-09-22T09:00:00.000Z",
        "org": {
          "variables": [
            { "id": "var-company", "key": "COMPANY", "value": "Northwind Health", "label": "Organization name" },
            { "id": "var-ehr", "key": "EHR", "value": "the patient records system", "label": "Clinical system of record" }
          ],
          "stakeholders": [
            { "id": "sh-ana", "name": "Ana Costa", "function": "Security / IR", "email": "ana.costa@example.org", "phone": "+1 555 0101", "decisionMaker": false, "notes": "Security lead; runs the incident bridge." },
            { "id": "sh-priya", "name": "Priya Shah", "function": "Executive", "email": "priya.shah@example.org", "phone": "+1 555 0102", "decisionMaker": true, "notes": "COO. Can authorize taking clinical systems offline." },
            { "id": "sh-tom", "name": "Tom Reyes", "function": "Communications", "email": "tom.reyes@example.org", "phone": "+1 555 0103", "decisionMaker": false, "notes": "" }
          ]
        },
        "exercises": [
          {
            "id": "ex-ransom-q4",
            "name": "Q4 ransomware tabletop",
            "status": "closed",
            "created_at": "2026-08-03T09:00:00.000Z",
            "updated_at": "2026-09-22T09:00:00.000Z",
            "date": "2026-09-15",
            "startTime": "09:30",
            "durationMin": 90,
            "location": "Board room, 3rd floor",
            "format": "In-person",
            "difficulty": "Moderate",
            "variables": [
              { "id": "var-oncall", "key": "ON-CALL", "value": "the infrastructure on-call engineer", "label": "Who gets paged first" }
            ],
            "objectives": [
              { "id": "obj-1", "text": "Determine who can authorize taking [EHR] offline, and how fast they can be reached.", "result": "partial", "notes": "The COO was named, but no backup when she is unreachable." },
              { "id": "obj-2", "text": "Test how fast a holding statement can go to staff.", "result": "met", "notes": "Draft ready in 25 minutes." }
            ],
            "scope": { "text": "Clinical and corporate IT at the main campus. Out of scope: the two partner clinics.", "worldwide": false },
            "assumptions": ["Detection tooling worked and produced the alerts described."],
            "groundRules": [
              "No-fault: nothing said here is used in performance reviews.",
              "Play the plan as it is today, not as it should be.",
              "Everything is labeled EXERCISE."
            ],
            "scenario": {
              "threatType": "Ransomware / extortion",
              "triggerEvent": "Staff report that files on the shared drive will not open and a ransom note appears.",
              "orgContext": "A 300-bed regional hospital with one data center and a managed backup service.",
              "escalationArc": "Encrypted files, then the attacker claims data theft, then local press calls.",
              "resolution": "Systems restored from backup; a public statement issued.",
              "background": "It is a Tuesday morning at [COMPANY]. Clinics are busy and [EHR] is running normally.",
              "facilitatorSetupNotes": "Establish what normal looks like before the first inject."
            },
            "participants": ["sh-ana", "sh-priya", "sh-tom"],
            "exerciseRoles": { "facilitator": "Ana Costa", "scribe": "Sam Lee", "evaluator": "", "sme": "" },
            "planningChecklist": [
              { "id": "ck-1", "phase": "4wk", "text": "Define 3 to 5 specific, observable objectives.", "done": true },
              { "id": "ck-2", "phase": "2wk", "text": "Draft the scenario and inject sequence.", "done": true },
              { "id": "ck-3", "phase": "1wk", "text": "Send the pre-read; confirm attendance.", "done": true },
              { "id": "ck-4", "phase": "dayof", "text": "Arrive early; test the room and tech.", "done": true },
              { "id": "ck-5", "phase": "after", "text": "Write the AAR; assign owners and dates.", "done": false }
            ],
            "brief": {
              "purpose": "Rehearse the first two hours of a ransomware incident and confirm who makes the big calls.",
              "whatToExpect": "A 90-minute facilitated discussion. No laptops, no live systems.",
              "whatToBring": "Your knowledge of how your area really works."
            },
            "injects": [
              {
                "id": "inj-1", "num": 1, "title": "Files will not open", "timeboxMin": 20,
                "primaryFunctions": ["Security / IR", "IT / Infrastructure"],
                "objectivesTested": ["obj-1"],
                "readAloud": "At 09:40 the help desk has twelve tickets: shared-drive files will not open. [ON-CALL] sees a ransom note on the file server.",
                "openingQuestion": "Walk me through your first fifteen minutes.",
                "probingQuestions": ["Who decides whether to take [EHR] offline?", "What if that person is unreachable?"],
                "whatGoodLooksLike": ["A named incident lead within 15 minutes."],
                "likelyGaps": ["No backup approver for a clinical shutdown."],
                "facilitatorNotes": "",
                "isCurveball": false
              },
              {
                "id": "inj-2", "num": 2, "title": "The press calls", "timeboxMin": 15,
                "primaryFunctions": ["Communications", "Executive"],
                "objectivesTested": ["obj-2"],
                "readAloud": "A local reporter calls: a source says [COMPANY] has been hacked. Is patient data safe?",
                "openingQuestion": "Who answers this call, and what do they say?",
                "probingQuestions": ["Who approves the statement?"],
                "whatGoodLooksLike": ["A holding statement approved by one named person."],
                "likelyGaps": ["Several people talk to the press."],
                "facilitatorNotes": "Drop this early if the room is comfortable.",
                "isCurveball": true
              }
            ],
            "ros": { "welcomeMin": 10, "setupMin": 10, "hotwashMin": 20 },
            "hotwash": {
              "reactions": ["tense", "clearer than last year"],
              "whatWorked": ["The help desk escalated within minutes."],
              "whatHard": ["Nobody knew who approves a clinical shutdown after hours."],
              "topImprovements": ["Name a backup approver for clinical shutdowns."],
              "parkingLot": ["Review the managed backup contract."]
            },
            "findings": [
              { "id": "find-1", "num": 1, "text": "No backup approver exists for taking [EHR] offline when the COO is unreachable.", "impact": "H", "recommendation": "Name and train a backup approver; add both to the on-call sheet." },
              { "id": "find-2", "num": 2, "text": "The press line was not routed to Communications.", "impact": "M", "recommendation": "Route media calls to Communications and brief reception." }
            ],
            "improvementPlan": [
              { "id": "act-1", "ttxId": "TTX-01", "action": "Name a backup approver for clinical shutdowns.", "owner": "Priya Shah", "priority": "H", "due": "2026-10-30", "status": "open", "findingId": "find-1" },
              { "id": "act-2", "ttxId": "TTX-02", "action": "Brief reception on routing media calls.", "owner": "Tom Reyes", "priority": "M", "due": "2026-10-16", "status": "open", "findingId": "" }
            ],
            "captures": [
              { "id": "cap-1", "ts": "2026-09-15T13:52:00.000Z", "text": "Help desk escalated fast." }
            ],
            "aar": {
              "execSummary": "Security, leadership and communications staff walked through a ransomware incident. Escalation was fast, but no one could approve a clinical shutdown after hours.",
              "strengths": ["Fast escalation from the help desk."],
              "frameworkMapping": "NIST SP 800-61 / CSF 2.0 · HSEEP",
              "handoff": { "keeper": "", "risk": "" }
            }
          }
        ]
      }
    ],
    "activeSlug": "northwind-health-ttx-program",
    "prefs": { "theme": null, "accent": null }
  }
}
```

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

- The program opens as a new tab, **Northwind Health TTX program**, with slug
  `northwind-health-ttx-program`.
- The exercise card reads *Closed*, *Ransomware / extortion*, *2 objectives*,
  *2 injects*, and shows **no** placeholder count: `[COMPANY]` and `[EHR]`
  resolve from the program, `[ON-CALL]` from the exercise.
- The run-of-show runs Welcome 9:30, Scenario setup 9:40, Inject 1 at 9:50,
  Inject 2 (curveball) at 10:10, Hotwash 10:25, ending 10:45. That is 75
  minutes, inside the planned 90.
- Inject 1 tests objective #1 and inject 2 tests objective #2.
- Action `TTX-01` addresses finding #1, so it rides along as that risk's
  treatment when the findings are sent to PumaRisk. The next action added in
  the app will be `TTX-03`.
