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

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

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

**Import replaces everything.** Before it changes anything the app asks:

> **Import backup**
> Import 1 engagement(s)? This replaces everything currently open.
> [Cancel] [Replace & import]

On **Replace & import**, every engagement currently in the app is replaced by
the engagements in the file, and the app says *"Imported 1 engagement(s)."*
There is no merge. To add work to an existing engagement instead, use a test
plan (§10).

---

## 1. The short version

If you only read one section, read this one.

1. Wrap your engagements in the envelope from §2. Import reads only
   `data.engagements`.
2. Write **every field** of every record, using the shapes in §3 and §4. Use
   `""`, `[]` or `false` for "nothing" in text, list and yes/no fields, and
   `null` only where §4 says `null` (timestamps, ratings, `clonedFrom`).
3. Use the **exact** enum ids from §4. A value the app does not recognize is
   **silently replaced with the default**, which can change a test's outcome:
   `"prevented": "Yes"` becomes `"na"`.
4. Every name in a test's `sources`, `target`, `tools` and `controls` must
   appear, **spelled identically**, in the engagement's `assets` lists. See §5.
5. `ttp` is an ATT&CK Enterprise technique id, upper case (`T1059.001`).
   `tactic` is one of the 14 tactic ids in §4.6 and should be a tactic that
   technique belongs to.
6. A test's state is **not stored**. It comes from `startTime` and `endTime`.
   Its outcome is **not stored** either. It comes from `prevented`, `alerted`,
   `logged` and the state. See §6.
7. Keep the result fields consistent the way the app keeps them: an alert
   implies `logged: "yes"`, a severity implies an alert, and `"na"` prevention
   has no prevention rating. See §6.4.
8. Timestamps are full ISO 8601 UTC datetimes, e.g.
   `"2026-10-13T14:00:00.000Z"`.
9. Leave `evidence` as `[]` in a generated pack. Evidence files are never
   inside a pack. See §4.9.
10. Check the result against the checklist in §11.

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

---

## 2. The envelope

```json
{
  "format": "pumapack",
  "version": 1,
  "app": "pumapurple",
  "app_version": "generated",
  "exported_at": "2026-10-16T17:00:00.000Z",
  "data": {
    "schema": 1,
    "activeSlug": "northwind-q4",
    "engagements": [ { "...one engagement object, see §3..." } ]
  }
}
```

| Key | Value | Notes |
|---|---|---|
| `format` | `"pumapack"` | Not checked on import, but write it; tooling checks it. |
| `version` | `1` | The envelope format. Not checked. |
| `app` | `"pumapurple"` | Not checked, but write it. |
| `app_version` | any string | The build that wrote the file. Free text; the app writes a short build id or `"dev"`. |
| `exported_at` | ISO 8601 datetime | Informational. |
| `data.schema` | `1` | The data schema. Not checked on import; write `1`. |
| `data.activeSlug` | an engagement `slug` | The engagement that opens first. If it matches no engagement in the file, the first one opens. |
| `data.engagements` | array of engagement objects | One or more. |

What the importer actually requires:

- The file is valid JSON. If not: *"That file is not valid JSON."*
- It has an array at `data.engagements`. If the file has no `data` key, the
  importer looks for `engagements` at the top level instead, so the bare
  object `{ "schema": 1, "activeSlug": "...", "engagements": [...] }` also
  imports. If neither is an array: *"No engagements found in that file."*
  - A bare engagement object, with no `engagements` wrapper, is **rejected**.
  - The per-engagement **Results (JSON)** export, a bare array of tests, is
    **rejected**. It is a report, not a backup.
  - A test plan (`.pumaplan`) dropped on the window is **rejected** the same
    way. Plans have their own importer (§10).

Every other envelope key is ignored.

### Which pack is which

The app writes the same envelope in two places:

| Where | What `data.engagements` holds |
|---|---|
| Topbar **Export**, or `⌘S` / `Ctrl+S` | Every engagement. This is the full backup. |
| An engagement tab's menu, or the offer made when you close a tab | That one engagement. |

Both import the same way, and **both replace everything**. Importing a
one-engagement pack leaves the app holding only that engagement.

---

## 3. The engagement object

```json
{
  "slug": "northwind-q4",
  "name": "Northwind Q4 purple team",
  "createdAt": "2026-10-12T09:00:00.000Z",
  "accent_color": "#b45cd8",
  "assets": { "sources": [], "targets": [], "tools": [], "controls": [] },
  "tagRegistry": [],
  "tagOrder": [],
  "tests": []
}
```

| Field | Type | Notes |
|---|---|---|
| `slug` | string | The engagement's identity. Lower-case `a-z0-9` and `-`, at most 40 characters, unique within the file. If a slug repeats, the later engagement gets one made from its name (`northwind-q4-purple-team`, `...-2`). |
| `name` | string | Shown on the tab. `""` becomes `"Engagement 1"`, `"Engagement 2"` and so on, by position. |
| `createdAt` | ISO 8601 datetime | Kept as written. Missing becomes the time of import. |
| `accent_color` | `#rrggbb` | The tab color. `"#b45cd8"` is the default. Not validated. |
| `assets` | object of four string arrays | The engagement's asset lists. See §4.7 and §5. A missing or non-array list becomes `[]`. |
| `tagRegistry` | array | Custom tags and hidden defaults. See §7. |
| `tagOrder` | array of strings | Tag order in the pickers. See §7. |
| `tests` | array of test objects | See §4. Use `[]` when empty. |

**Any other key on an engagement is silently dropped.** Unknown keys on
*tests* survive (§8).

---

## 4. The test record

```json
{
  "id": "t-ps-enc",
  "name": "Encoded PowerShell download cradle",
  "objective": "Run a base64-encoded download cradle and check that it raises an alert.",
  "ttp": "T1059.001",
  "tactic": "execution",
  "command": "powershell.exe -enc SQBFAFgA...",
  "startTime": "2026-10-13T15:00:00.000Z",
  "endTime": "2026-10-13T15:04:00.000Z",
  "modified": "2026-10-13T15:20:00.000Z",
  "sources": ["RT-JUMP-01"],
  "target": "WKS-FIN-07",
  "tools": ["Atomic Red Team"],
  "controls": ["Microsoft Defender", "Splunk"],
  "prevented": "no",
  "preventionRating": null,
  "focus": "detect",
  "urgency": "med",
  "alerted": "yes",
  "logged": "yes",
  "alertSeverity": "high",
  "detectedAt": null,
  "detectionRating": 3.5,
  "tags": ["lolbin"],
  "notes": "",
  "comments": [],
  "evidence": [],
  "inReport": true,
  "clonedFrom": null
}
```

A test is one action run against one target, and what the defenses did about
it. The fields fall into the groups below.

### Conventions for every test

- **`id`** is any non-empty string, unique within the engagement.
  - The app generates UUIDs (`"497fd0ed-58f2-4250-beb1-a4d433aaa752"`). Short
    readable ids (`"t-lsass"`) work just as well and are kept as written.
  - A missing or `""` id is replaced with a new UUID on import, which breaks
    any `clonedFrom` that pointed at it.
- **Missing keys are filled with defaults.** An explicit `null` in a text
  field is kept as `null` and shows as blank, so write `""` instead.
- **Enum fields are checked on import.** An unknown value is replaced with
  the default listed below, without a warning.

### 4.1 Identity and description

| Field | Type | Meaning |
|---|---|---|
| `name` | string | Short title shown in the grid. The grid sorts by it. |
| `objective` | string | What the test is trying to prove. |
| `command` | string | What was run: a command line, or a description of the action. Shown in monospace. Use `\n` for more than one line. |
| `notes` | string | Findings in the tester's words. Appears in the report. |

### 4.2 Technique and tactics

| Field | Type | Meaning |
|---|---|---|
| `ttp` | string | An ATT&CK Enterprise technique or sub-technique id, e.g. `"T1547.001"`. `""` for none. |
| `tactic` | tactic id or `""` | The **primary** tactic. Every per-tactic chart, the Stats view and the workbook's Coverage sheet count the test under this one. |
| `tactics` | array of tactic ids, **optional** | An explicit tactic set. Leave the key **out** to follow the technique. |

- **Techniques.** The app carries the full ATT&CK Enterprise catalogue at
  version 19 (697 techniques). Ids are case sensitive: `T1059.001`, not
  `t1059.001`. An id that is not in the catalogue is kept, but the drawer
  says *Not in the catalogue*, and the ATT&CK matrix leaves it out and counts
  it as an off-catalogue id.
  - Version 19 renumbered some techniques. `T1562` (Impair Defenses) and
    its sub-techniques, `T1070.001` and `T1070.002` are not in the
    catalogue; version 19 moved that family under `T1685` (Disable or Modify
    Tools) and its sub-techniques. Use the version 19 id.
- **Tactic ids** are exactly these 14:

  | Id | Shown as |
  |---|---|
  | `reconnaissance` | Reconnaissance |
  | `resource-development` | Resource Development |
  | `initial-access` | Initial Access |
  | `execution` | Execution |
  | `persistence` | Persistence |
  | `privilege-escalation` | Privilege Escalation |
  | `defense-evasion` | Defense Evasion |
  | `credential-access` | Credential Access |
  | `discovery` | Discovery |
  | `lateral-movement` | Lateral Movement |
  | `collection` | Collection |
  | `command-and-control` | Command and Control |
  | `exfiltration` | Exfiltration |
  | `impact` | Impact |

- **`tactic` is not validated.** A misspelled tactic is kept and shown as
  typed, no chip is marked primary, and it gets its own row in the
  per-tactic totals. A test with `tactic: ""` is
  counted under *(none)*.
- **Pick a tactic the technique belongs to.** Many techniques belong to one
  tactic. Some belong to several (`T1078` Valid Accounts is in four); pick the
  one this test exercises. When you set a technique in the app, it picks the
  technique's first listed tactic.
- **Tactic chips are derived.** With `tactics` absent, a test shows a chip for
  every tactic its technique belongs to, with `tactic` marked as primary.
- **`tactics`** is three-valued, so be deliberate:
  - key absent: follow the technique (the normal case);
  - an array: exactly this set. It must include `tactic`, or the totals count
    a tactic the test does not show. Unknown ids and duplicates are removed
    on import;
  - `[]`: a real answer meaning "no tactics", kept on import.
  - Anything that is not an array (e.g. a string) is removed, so the test
    follows its technique again.

### 4.3 Timing and state

| Field | Type | Meaning |
|---|---|---|
| `startTime` | ISO datetime or `null` | When the action started. |
| `endTime` | ISO datetime or `null` | When it finished. |
| `modified` | ISO datetime | When the test was last edited. The app updates it on every edit; import keeps it as written. |

State is **derived** from these two, never stored:

| `startTime` | `endTime` | State |
|---|---|---|
| `null` | `null` | Pending |
| a datetime | `null` | Running |
| any | a datetime | Complete |

- An `endTime` earlier than `startTime` is kept, flagged as a warning in the
  drawer, and gives the test no duration.
- A `state` key on a test is removed on import. It carries no meaning.
- Write datetimes in UTC with the `Z` suffix. A datetime with no zone, such
  as `"2026-10-13 14:00"`, is read as local time in the viewer's browser.

### 4.4 Assets

| Field | Type | Meaning |
|---|---|---|
| `sources` | array of strings | Where the action was run from. Names from `assets.sources`. |
| `target` | string | The **one** host or service it was run against. A name from `assets.targets`, or `""`. A second target is a second test. |
| `tools` | array of strings | What it was run with. Names from `assets.tools`. |
| `controls` | array of strings | The defenses being measured. Names from `assets.controls`. The Stats view tallies each control's prevented and detected counts. |

A non-array `sources`, `tools` or `controls` becomes `[]` on import. An older
file's single-string `tool` field is moved into `tools` and removed.

### 4.5 Results

| Field | Values | Default | Meaning |
|---|---|---|---|
| `prevented` | `"yes"` \| `"partial"` \| `"no"` \| `"na"` | `"na"` | Did a control stop the action? `"na"` means prevention was not in question. |
| `preventionRating` | `null` or 0 to 5 | `null` | How good the prevention was. |
| `alerted` | `"yes"` \| `"no"` | `"no"` | Did an alert reach the SOC? |
| `logged` | `"yes"` \| `"no"` | `"no"` | Did telemetry of the action exist, whether or not anything fired? |
| `alertSeverity` | `""` \| `"critical"` \| `"high"` \| `"med"` \| `"low"` \| `"info"` | `""` | The alert's severity, shown as Critical, High, Medium, Low, Informational. `""` when nothing alerted. |
| `detectedAt` | ISO datetime or `null` | `null` | When the alert fired. Optional. See §6.3. |
| `detectionRating` | `null` or 0 to 5 | `null` | How good the detection was. |
| `focus` | `"prevent"` \| `"detect"` \| `"na"` | `"na"` | What the test was meant to prove. A test with `"na"` is never reported as a gap. |
| `urgency` | `"low"` \| `"med"` \| `"high"` | `"med"` | How urgently a gap from this test should be fixed. Ranks the gap list. |

- An unrecognized `prevented`, `alerted`, `logged`, `focus`, `urgency` or
  non-empty `alertSeverity` is replaced with the default in this table.
  Values are case sensitive: `"Yes"` is not `"yes"`.
- Ratings: see §6.2.
- An unparseable `detectedAt` becomes `null`.

### 4.6 Tags

| Field | Type | Meaning |
|---|---|---|
| `tags` | array of strings | Labels for filtering. Matched case-insensitively. See §7. |

### 4.7 Report and lineage

| Field | Type | Meaning |
|---|---|---|
| `inReport` | boolean | `false` leaves the test out of the Markdown and Word reports. **Anything other than `false` becomes `true`.** |
| `clonedFrom` | test id or `null` | Set on a **retest**: the id of the earlier test this one re-runs, in the same engagement. See §6.5. |

### 4.8 `comments[]`

```json
{ "id": "c-ps-1", "ts": "2026-10-13T15:18:00.000Z", "body": "Alert title was generic." }
```

| Field | Type | Meaning |
|---|---|---|
| `id` | string | Unique within the test. The app writes a UUID. |
| `ts` | ISO datetime | When the comment was made. Shown in local time. |
| `body` | string | Plain text. |

Comments show in array order. The app appends, so keep them oldest first.

### 4.9 `evidence[]`

```json
{
  "key": "5b1e...uuid", "name": "defender-alert.png", "size": 48213,
  "type": "image/png", "sha256": "9f2c...64 hex chars", "addedAt": "2026-10-13T14:02:00.000Z",
  "caption": "The Defender alert as the SOC saw it"
}
```

An evidence entry describes a file attached to a test. **The file itself is
never in the pack.** It lives in the browser that attached it, looked up by
`key`. The pack carries only this description, so a report can show which
file was collected and its SHA-256.

- In an export, keep evidence entries exactly as they are. The files open
  again when the pack goes back into the same browser.
- In a pack imported into a *different* browser, or a **generated** pack, an
  entry has no file behind it. It still lists in the drawer and the reports,
  but opening it says *"That file is no longer in local storage."*
- So a generated pack should use `"evidence": []`. Files are attached in the
  app.
- A missing `caption` becomes `""`.

---

## 5. Cross-references

All references stay within one engagement.

| From | Field | To |
|---|---|---|
| test | `clonedFrom` | `tests[].id` of an earlier test, or `null` |
| test | `sources[]` | a name in `assets.sources` |
| test | `target` | a name in `assets.targets`, or `""` |
| test | `tools[]` | a name in `assets.tools` |
| test | `controls[]` | a name in `assets.controls` |
| test | `tactic`, `tactics[]` | the tactic ids in §4.2 |
| test | `ttp` | an ATT&CK Enterprise v19 technique id |
| envelope | `data.activeSlug` | `engagements[].slug` |
| `tagOrder[]` | entries | tag names, lower case |

Asset names are compared **exactly**, including case. A name on a test that
is not in the matching `assets` list is kept, counted and exported, but the
drawer does not show it: the chip pickers offer only the listed names, and
the target picker shows no target. It cannot be removed from the drawer, although
choosing another target replaces a stray `target`. Asset list order is the
order the app shows them in.

A `clonedFrom` that points at no test is ignored: the test is not treated as
a retest, and the report says it re-tests *a deleted test*.

---

## 6. How the app reads a test

### 6.1 Outcome

Every test has exactly one outcome, derived by strict precedence. The first
row that matches wins:

| Order | Condition | Outcome |
|---|---|---|
| 1 | `prevented` is `"yes"` or `"partial"` | **Prevented** |
| 2 | `alerted` is `"yes"` | **Alerted** |
| 3 | `logged` is `"yes"` | **Logged** (telemetry existed, nothing fired) |
| 4 | the test is Complete (§4.3) | **Missed** |
| 5 | otherwise | **Untested** |

So a test that was both prevented and alerted counts once, as Prevented, and
the four outcomes plus Untested always add up to the number of tests. An unrun
test is Untested, never Missed.

A **gap** is a test whose outcome is Missed or Logged and whose `focus` is not
`"na"`. Gaps are listed worst first: by `urgency` (high, med, low), then by
name.

### 6.2 Ratings

- `preventionRating` and `detectionRating` are `null` (not rated) or a number
  from 0 to 5 in steps of 0.5.
- `0` is a real score, meaning "happened but was worthless". `null` means
  nobody scored it. Averages skip `null` and count `0`.
- On import a rating is rounded to the nearest 0.5 and clamped to 0 to 5
  (`3.7` becomes `3.5`, `9` becomes `5`). A numeric string such as `"4"`
  becomes `4`. `""` and anything non-numeric become `null`.

### 6.3 Time to detect

For a test with `alerted: "yes"` and a `startTime`, time to detect is
`detectedAt` minus `startTime`.

- `detectedAt: null` on an alerted test means "the alert fired while we were
  watching" and counts as **zero**. It is the normal case, not missing data.
- The Stats view averages over every alerted test and says how many had a
  recorded time.
- `detectedAt` on a test that did not alert is ignored.

### 6.4 Keeping results consistent

The app keeps these rules when a result is set in the drawer. **Import does
not enforce them**, so a generated pack must:

| If | Then |
|---|---|
| `alerted` is `"yes"` | `logged` is `"yes"`. You cannot alert on data you never collected. |
| `alertSeverity` is set | `alerted` and `logged` are `"yes"`. |
| `alerted` is `"no"` | `alertSeverity` is `""`. |
| `alerted` and `logged` are both `"no"` | `detectionRating` is `null`. |
| `prevented` is `"na"` | `preventionRating` is `null`. |

A pack that breaks them still imports. The outcome follows §6.1 regardless,
but the drawer and exports show combinations the app never produces, such as
a severity on a test that did not alert.

### 6.5 Retests

A retest is a second run of an earlier test, usually after a fix. Write it as
its own test with `clonedFrom` set to the earlier test's `id`. The app
compares the two outcomes, ranked Prevented > Alerted > Logged > Missed:

| Retest | Verdict |
|---|---|
| Untested | Not yet re-run |
| better than the original | Improved |
| the same | No change |
| worse | Regressed |

A retest **closes a gap** when the original was a gap and the retest is
scored and is not one. The original stays in the gap list; the Stats view and
report count it as closed. When the app makes a retest it copies the
original, adds " (retest)" to the name, and clears the timestamps, every
result and rating, the comments and the evidence.

---

## 7. Tags and the tag palette

Tags on a test are plain strings. The palette decides which tags the pickers
offer and what color group each belongs to.

**Default tags** are built in, in three groups, and need no registry entry:

| Group id | Tags |
|---|---|
| `scope` | `internal`, `external`, `cloud`, `on-prem`, `ad`, `endpoint`, `network` |
| `tradecraft` | `lolbin`, `phishing`, `malware`, `ransomware-sim`, `hands-on-keyboard` |
| `workflow` | `retest`, `quick-win`, `needs-tuning`, `accepted-risk`, `blocked` |

Do **not** use tactic names as tags. Tactic chips come from the technique
(§4.2). Older files may carry tactic tags such as `priv-esc`; they still
render, but the app no longer offers them.

**`tagRegistry`** holds this engagement's changes to the defaults:

| Entry | Meaning |
|---|---|
| `{ "name": "crown-jewels", "group": "scope" }` | A custom tag offered in that group. `group` is `scope`, `tradecraft` or `workflow`. |
| `{ "name": "legacy-app" }` | A custom tag with no group. Offered under *Other*. |
| `{ "name": "ransomware-sim", "hidden": true }` | A default this engagement does not use. Hidden from the pickers; tests that already carry it keep it. |

- On import an unknown `group` is dropped, so the tag lands in *Other*.
  Keys other than `name`, `group` and `hidden` are dropped. Entries repeated
  case-insensitively keep only the first.
- A custom tag used on a test but **not** in the registry still shows on that
  test, under *Other*, but no picker offers it for other tests. Register every
  custom tag.

**`tagOrder`** is an optional list of tag names, lower case, giving the order
tags appear in within each group. Names not in it keep their natural order
(defaults first, then registry order). `[]` means natural order. On import
each entry is trimmed and lower-cased.

---

## 8. Editing an existing export

**Preserve**

- every `id`, `slug` and `clonedFrom` exactly. Retest lineage is by id;
- `createdAt`, `modified`, `startTime`, `endTime`, `detectedAt` and comment
  `ts` values you did not mean to change;
- every `evidence` entry, including `key` and `sha256`. The key is how the
  app finds the file, and the hash is the proof it is the file that was
  collected;
- unknown keys on tests. The app keeps and re-exports them;
- `app_version` and `exported_at` if you like. They are informational.

**The app recomputes, so do not store:** state, outcome, gaps, retest
verdicts, time-to-detect figures, tactic chips and every Stats number. None of
them is in the file.

**The app changes on import:**

| What | How |
|---|---|
| A missing or empty test `id` | Replaced with a new UUID. |
| An unrecognized enum value (§4.5) | Replaced with its default. |
| Ratings | Rounded to 0.5 and clamped to 0 to 5. |
| An unparseable `detectedAt` | `null`. |
| `inReport` | Anything but `false` becomes `true`. |
| A non-array `tactics` | Removed. |
| `state` on a test | Removed. |
| `tool` (an old single string) | Moved into `tools`, then removed. |
| Unknown keys on an engagement or registry entry | Removed. |
| A repeated `slug` | The later engagement gets a slug made from its name. |

**When you edit results**, update `modified` to the time of the edit, and
keep §6.4's rules. When you add a new test, give it a new id that no other
test in the engagement uses.

---

## 9. Things that go wrong

| Mistake | What happens |
|---|---|
| A single engagement with no `engagements` wrapper | Rejected: *"No engagements found in that file."* |
| The per-engagement Results (JSON) export, or a `.pumaplan`, through the topbar Import | Rejected: *"No engagements found in that file."* |
| Invalid JSON | Rejected: *"That file is not valid JSON."* |
| Expecting import to merge | It replaces every engagement in the app, after the confirm. |
| `"engagements": []` | The confirm asks to import 0 engagements. Accepting it replaces everything with one empty engagement named *Engagement 1*. |
| A `null` entry in `engagements` | Skipped, but still counted in the confirm and the *"Imported N engagement(s)."* message. |
| A `null` entry in `tests` | Becomes a blank, untitled test. |
| A `null` entry in `comments` or `evidence` | Imports without complaint, then that test's drawer will not open and the Markdown and CSV exports fail for the whole engagement. |
| Enum typo, e.g. `"prevented": "Yes"` or `"urgency": "urgent"` | Replaced with the default (`"na"`, `"med"`). A typo in `prevented` can change the outcome. |
| `"alertSeverity": "High"` | Becomes `""`. Severity ids are lower case, and Medium is `"med"`. |
| A misspelled `tactic` | Kept and shown as typed. No chip is marked primary, and it gets its own row in the per-tactic totals. |
| A lower-case or retired technique id | Kept, shown as *Not in the catalogue*, and left off the matrix. |
| `tactics` as a string | Removed; the test follows its technique. |
| An asset name on a test that is not in `assets`, or spelled differently | Kept and exported, but invisible in the drawer, so it cannot be removed there. |
| A custom tag not in `tagRegistry` | Shows on its own test, but is never offered for others. |
| `sources`, `tools` or `controls` as a string | Becomes `[]`. The value is lost. |
| `null` in a text field such as `name` or `notes` | Kept, shown as blank. Write `""`. |
| `"inReport": "no"` | Becomes `true`. Only `false` hides a test. |
| A `state` field instead of timestamps | Removed. The test is Pending unless it has `startTime` / `endTime`. |
| `alerted: "yes"` with `logged: "no"` | Kept as written. The outcome is Alerted, but the test shows a combination the app never produces. |
| Evidence entries in a generated pack | Listed, but opening one says *"That file is no longer in local storage."* |
| A datetime without `Z` | Read as the viewer's local time. |

---

## 10. Other files PumaPurple reads

These are separate formats with their own importer, not `.pumapack` files.
They are listed so you do not confuse them:

- **Test plan (`.pumaplan`)**: a list of tests with every result stripped
  out. Imported from the Tests view's **Import** menu, it **adds** unscored
  tests to the engagement you are in, creating any assets and tags it names.
  Written by the engagement's Export menu.
- **ATT&CK Navigator layer (`.json`)**: imported from the same menu, it adds
  one unscored test per catalogue technique in the layer.

Neither goes through the topbar Import button. Dropped on the window, both
are treated as a backup and rejected.

---

## 11. Checklist before handing a pack over

A pack that passes all of these opens with no warnings and nothing reset.

**Structure**
- [ ] The envelope matches §2, and `data.engagements` is a non-empty array
      with no `null` entries.
- [ ] `data.activeSlug` names one of the engagements.
- [ ] Every engagement has every field in §3, and every test every field in
      §4. `null` only in `startTime`, `endTime`, `detectedAt`, the two
      ratings and `clonedFrom`.
- [ ] No `null` entries inside `tests`, `comments` or `evidence`.
- [ ] Slugs are unique in the file; test ids are unique in their engagement.

**References**
- [ ] Every name in a test's `sources`, `target`, `tools` and `controls` is in
      the matching `assets` list, spelled identically.
- [ ] Every `clonedFrom` is `null` or the id of another test in the same
      engagement.
- [ ] Every custom tag used on a test is in `tagRegistry`.

**Values**
- [ ] Every enum value is one of the exact, lower-case ids in §4.5.
- [ ] `ttp` is an upper-case ATT&CK Enterprise v19 id; `tactic` is one of the
      14 ids in §4.2 and a tactic that technique belongs to.
- [ ] `tactics` is either absent or an array that includes `tactic`.
- [ ] Ratings are `null` or 0 to 5 in steps of 0.5.
- [ ] The rules in §6.4 hold for every test.
- [ ] Datetimes are ISO 8601 UTC with `Z`, and `endTime` is not before
      `startTime`.
- [ ] `evidence` is `[]` for anything you generated.

---

## 12. A complete example

One engagement with six tests: one Prevented, two Alerted, one Logged only,
one Missed and one not yet run. It includes a retest that closes a gap, a
comment, a custom tag, a hidden default tag, and a tactic override. It imports
with no warnings.

```json
{
  "format": "pumapack",
  "version": 1,
  "app": "pumapurple",
  "app_version": "generated",
  "exported_at": "2026-10-16T17:00:00.000Z",
  "data": {
    "schema": 1,
    "activeSlug": "northwind-q4",
    "engagements": [
      {
        "slug": "northwind-q4",
        "name": "Northwind Q4 purple team",
        "createdAt": "2026-10-12T09:00:00.000Z",
        "accent_color": "#b45cd8",
        "assets": {
          "sources": ["RT-JUMP-01"],
          "targets": ["WKS-FIN-07", "SRV-DC01"],
          "tools": ["Atomic Red Team", "Impacket"],
          "controls": ["Microsoft Defender", "Splunk"]
        },
        "tagRegistry": [
          { "name": "crown-jewels", "group": "scope" },
          { "name": "ransomware-sim", "hidden": true }
        ],
        "tagOrder": [],
        "tests": [
          {
            "id": "t-lsass",
            "name": "LSASS memory dump",
            "objective": "Dump LSASS with a signed tool and see whether the endpoint agent blocks it.",
            "ttp": "T1003.001",
            "tactic": "credential-access",
            "command": "procdump.exe -ma lsass.exe C:\\Users\\Public\\l.dmp",
            "startTime": "2026-10-13T14:00:00.000Z",
            "endTime": "2026-10-13T14:06:00.000Z",
            "modified": "2026-10-13T14:10:00.000Z",
            "sources": ["RT-JUMP-01"],
            "target": "WKS-FIN-07",
            "tools": ["Atomic Red Team"],
            "controls": ["Microsoft Defender"],
            "prevented": "yes",
            "preventionRating": 4.5,
            "focus": "prevent",
            "urgency": "high",
            "alerted": "yes",
            "logged": "yes",
            "alertSeverity": "critical",
            "detectedAt": "2026-10-13T14:01:00.000Z",
            "detectionRating": 4,
            "tags": ["crown-jewels"],
            "notes": "Blocked on access to the LSASS handle. The alert reached the SOC queue a minute later.",
            "comments": [],
            "evidence": [],
            "inReport": true,
            "clonedFrom": null
          },
          {
            "id": "t-ps-enc",
            "name": "Encoded PowerShell download cradle",
            "objective": "Run a base64-encoded download cradle and check that it raises an alert.",
            "ttp": "T1059.001",
            "tactic": "execution",
            "command": "powershell.exe -enc SQBFAFgAIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQAKQA=",
            "startTime": "2026-10-13T15:00:00.000Z",
            "endTime": "2026-10-13T15:04:00.000Z",
            "modified": "2026-10-13T15:20:00.000Z",
            "sources": ["RT-JUMP-01"],
            "target": "WKS-FIN-07",
            "tools": ["Atomic Red Team"],
            "controls": ["Microsoft Defender", "Splunk"],
            "prevented": "no",
            "preventionRating": null,
            "focus": "detect",
            "urgency": "med",
            "alerted": "yes",
            "logged": "yes",
            "alertSeverity": "high",
            "detectedAt": null,
            "detectionRating": 3.5,
            "tags": ["lolbin"],
            "notes": "",
            "comments": [
              { "id": "c-ps-1", "ts": "2026-10-13T15:18:00.000Z", "body": "Alert title was generic; asked the SOC to add the decoded command line." }
            ],
            "evidence": [],
            "inReport": true,
            "clonedFrom": null
          },
          {
            "id": "t-runkey",
            "name": "Registry Run key persistence",
            "objective": "Write a user Run key and check whether anything alerts on it.",
            "ttp": "T1547.001",
            "tactic": "persistence",
            "command": "reg add HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v Updater /d C:\\Users\\Public\\u.exe",
            "startTime": "2026-10-14T10:00:00.000Z",
            "endTime": "2026-10-14T10:05:00.000Z",
            "modified": "2026-10-14T10:30:00.000Z",
            "sources": ["RT-JUMP-01"],
            "target": "WKS-FIN-07",
            "tools": ["Atomic Red Team"],
            "controls": ["Splunk"],
            "prevented": "no",
            "preventionRating": null,
            "focus": "detect",
            "urgency": "high",
            "alerted": "no",
            "logged": "yes",
            "alertSeverity": "",
            "detectedAt": null,
            "detectionRating": 1.5,
            "tags": ["needs-tuning"],
            "notes": "The registry write is in Splunk, but no rule fires on it.",
            "comments": [],
            "evidence": [],
            "inReport": true,
            "clonedFrom": null
          },
          {
            "id": "t-runkey-rt",
            "name": "Registry Run key persistence (retest)",
            "objective": "Re-run after the SIEM team added a rule for user Run key writes.",
            "ttp": "T1547.001",
            "tactic": "persistence",
            "command": "reg add HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v Updater /d C:\\Users\\Public\\u.exe",
            "startTime": "2026-10-16T10:00:00.000Z",
            "endTime": "2026-10-16T10:05:00.000Z",
            "modified": "2026-10-16T10:20:00.000Z",
            "sources": ["RT-JUMP-01"],
            "target": "WKS-FIN-07",
            "tools": ["Atomic Red Team"],
            "controls": ["Splunk"],
            "prevented": "no",
            "preventionRating": null,
            "focus": "detect",
            "urgency": "high",
            "alerted": "yes",
            "logged": "yes",
            "alertSeverity": "med",
            "detectedAt": "2026-10-16T10:03:00.000Z",
            "detectionRating": 4,
            "tags": ["retest"],
            "notes": "The new rule fired about three minutes after the write.",
            "comments": [],
            "evidence": [],
            "inReport": true,
            "clonedFrom": "t-runkey"
          },
          {
            "id": "t-rdp",
            "name": "RDP to the domain controller",
            "objective": "Move laterally to the DC over RDP with a stolen admin account.",
            "ttp": "T1021.001",
            "tactic": "lateral-movement",
            "command": "mstsc.exe /v:SRV-DC01",
            "startTime": "2026-10-15T13:00:00.000Z",
            "endTime": "2026-10-15T13:20:00.000Z",
            "modified": "2026-10-15T13:25:00.000Z",
            "sources": ["RT-JUMP-01"],
            "target": "SRV-DC01",
            "tools": ["Impacket"],
            "controls": ["Microsoft Defender", "Splunk"],
            "prevented": "no",
            "preventionRating": null,
            "focus": "detect",
            "urgency": "high",
            "alerted": "no",
            "logged": "no",
            "alertSeverity": "",
            "detectedAt": null,
            "detectionRating": null,
            "tags": ["crown-jewels"],
            "notes": "No logon event from the jump box reached the SIEM.",
            "comments": [],
            "evidence": [],
            "inReport": true,
            "clonedFrom": null
          },
          {
            "id": "t-valid-acct",
            "name": "Log on with a dormant contractor account",
            "objective": "Use a dormant but enabled account to sign in and stay signed in.",
            "ttp": "T1078",
            "tactic": "initial-access",
            "tactics": ["initial-access", "persistence"],
            "command": "",
            "startTime": null,
            "endTime": null,
            "modified": "2026-10-12T09:00:00.000Z",
            "sources": [],
            "target": "",
            "tools": [],
            "controls": ["Splunk"],
            "prevented": "na",
            "preventionRating": null,
            "focus": "detect",
            "urgency": "low",
            "alerted": "no",
            "logged": "no",
            "alertSeverity": "",
            "detectedAt": null,
            "detectionRating": null,
            "tags": [],
            "notes": "",
            "comments": [],
            "evidence": [],
            "inReport": false,
            "clonedFrom": null
          }
        ]
      }
    ]
  }
}
```

What the app shows for this file, as a check on your own reasoning. These
figures were produced by importing this exact file into the app:

- Outcomes: `t-lsass` Prevented; `t-ps-enc` and `t-runkey-rt` Alerted;
  `t-runkey` Logged; `t-rdp` Missed; `t-valid-acct` Untested (Pending, since
  it has no timestamps). Five of six tests are scored.
- Gaps: `t-rdp` then `t-runkey`. Both are high urgency, so they are listed by
  name.
- The retest `t-runkey-rt` is **Improved** over `t-runkey` (Logged to
  Alerted) and **closes** that gap: 1 gap closed.
- Average prevention rating 4.5 (one rated test). Average detection rating
  3.25, over the four tests that have one.
- Average time to detect is 1m 20s over three alerting tests, two of them
  with a recorded time: `t-lsass` 1 minute, `t-runkey-rt` 3 minutes, and
  `t-ps-enc` counted as real time (zero).
- `t-valid-acct` shows two tactic chips, Initial Access (primary) and
  Persistence, and is left out of the report.
- The matrix reads *5 of 697 techniques tested, across 7 tactics*. `T1547.001`
  and `T1078` belong to several tactics, so they appear in each of those
  columns.
