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

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

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

**Import replaces everything.** Every hunt program in the browser is swapped
for the programs in the file. It never merges. If the browser already holds at
least one hunt, the user first sees a *"Replace current data?"* dialog with
**Cancel**, **Replace** and **Export current, then import**. With no hunts
yet, the import goes straight through.

The quick save (`⌘/Ctrl+S`) writes the same structure with a `.json`
extension, and it imports the same way. A separate **Merge Workspace…**
command (right-click a program tab) reads the same file but merges one of its
programs into an existing one instead of replacing; see §7.

---

## 1. The short version

If you only read one section, read this one.

1. Wrap your programs in the envelope from §2. `puma.app` must be
   `"pumahunter"`, and import reads only `data.workspaces` and
   `data.activeSlug`.
2. A **program** (the app calls it a workspace) holds hunts. Give it a
   unique `slug`, and put at least one program in the file. See §3.
3. Write **every field** of every record, using the shapes in §4. Use `""`,
   `[]`, `{}` or `false` for "nothing". **Never write `null`**, except for
   a hunt's `fromTemplate`.
4. Every array field must be an array. A string where `steps`, `findings` or
   `improvements` belongs aborts the whole import with no message.
5. Number hunts `H-001`, `H-002` and so on, and set the program's
   `hunt_seq` to the highest number used. The importer does not renumber.
   See §6.1.
6. Keep `status`, `stage` and `outcome` consistent: a `complete` hunt is in
   the `conclude` stage with an outcome other than `open`. See §6.2.
7. Every data-source id used by a hunt must be a built-in id (§6.4) or a
   custom data source declared on the same program.
8. Enum values are not checked on import. Use the exact ids in §4.
9. Check the result against the checklist in §9.

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

---

## 2. The envelope

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumahunter",
    "appVersion": "generated",
    "format": 1,
    "exportedAt": "2026-09-28T09:00:00.000Z",
    "kind": "backup",
    "title": "PumaHunter backup"
  },
  "data": {
    "schema": 2,
    "workspaces": [ { "...one program object, see §3..." } ],
    "activeSlug": "northwind-q4"
  }
}
```

| Key | Value | Notes |
|---|---|---|
| `$schema` | `"https://pumaworx.dev/pumapack/v1"` | Not checked. Write it. |
| `puma.app` | `"pumahunter"` | **Required.** Any other value is refused. |
| `puma.appVersion` | any string | Free text. The app writes its build id, or `"dev"`. |
| `puma.format` | `1` | Not checked. Write it. |
| `puma.exportedAt` | ISO 8601 datetime | Informational. |
| `puma.kind` | `"backup"` | Not checked. Write it. |
| `puma.title` | string | Cosmetic. The app writes `"PumaHunter backup"`. |
| `data.schema` | `2` | The current schema. Not read on import: the data is stored as schema 2 whatever this says, and no upgrade is run. Write `2` and use the schema 2 shapes. |
| `data.workspaces` | array of program objects | **Required.** One or more. |
| `data.activeSlug` | a program's `slug` | Which program opens first. If it matches no program, the first program opens. |

What the importer actually requires, with the exact message when it refuses:

| Condition | Message |
|---|---|
| The file is valid JSON | *"Import failed — Not valid JSON."* |
| There is a `puma` object | *"Import failed — Missing the puma envelope — not a .pumapack file."* |
| `puma.app` is `"pumahunter"` | *"Import failed — That pack is from pumacase. Open it there instead."* (with whatever app name the file carries) |
| `data.workspaces` is an array | *"Import failed — No hunt programs in this pack."* |

A bare `data` object with no envelope is rejected with the second message.
Every other envelope key is ignored.

On success the app says *"Imported N program(s) · M hunt(s)"*, counting every
program and every hunt in the file.

PumaHunter does not import other apps' packs. It can *write* packs for
PumaCase and PumaRisk from a hunt (the hunt's hand-off export), but those are
those apps' formats and are not described here.

---

## 3. The program object

A program is one tab in the app: one client, engagement or environment.

```json
{
  "slug": "northwind-q4",
  "name": "Northwind Retail Q4",
  "accent_color": "#4a9d5f",
  "hunter": "Dana Okafor",
  "hunts": [ ],
  "hunt_seq": 0,
  "customDataSources": [ ],
  "customTemplates": [ ],
  "collectedSources": [ ],
  "created_at": "2026-09-10T08:30:00.000Z",
  "updated_at": "2026-09-25T10:20:00.000Z"
}
```

| Field | Type | Notes |
|---|---|---|
| `slug` | string | The program's identity. Lower-case `a-z0-9` and `-`, at most 40 characters, **unique within the file**. Required: the importer does not fill in a missing one. |
| `name` | string | Shown on the tab. |
| `accent_color` | `#rgb` or `#rrggbb` | Tab color. Missing or `""` becomes `#4a9d5f`. |
| `hunter` | string | The default assignee: a hunt created in the app later is assigned to this person. `""` for none. |
| `hunts` | array of hunts | See §4.1. Display order, newest first. `[]` when empty. |
| `hunt_seq` | integer | The highest hunt number handed out. See §6.1. If it is missing, the number of hunts is used. |
| `customDataSources` | array | Data sources this program adds to the built-in list. See §4.7. |
| `customTemplates` | array | Hunts saved to this program's library. See §4.8. |
| `collectedSources` | array of data-source ids | What this environment actually collects. See §4.9. |
| `created_at`, `updated_at` | ISO 8601 datetime | Full timestamps. `created_at` is shown in the program's About panel. |

Missing `customDataSources`, `customTemplates` or `collectedSources` become
`[]`. **Unknown keys at the program level are kept** and have no effect.

---

## 4. Record shapes

### Conventions for every record

- **`id`** is any non-empty string, unique within its own array in the
  program. The app generates ids like `h-mqsdgbscfaa33a4b7942`; short readable
  ids (`h-sched-tasks`, `s-st-1`) work just as well. A hunt or step with no
  `id` gets a fresh random one on import.
- **Defaults are filled in only for keys that are missing or empty.** Write
  every key anyway, so the file says what it means.
- **Enum values are not validated on import.** A misspelled status is kept,
  shows blank in its dropdown, and falls out of every count. Use the exact ids
  below.
- **Unknown keys on a hunt, step, finding or improvement are dropped** on
  import. Unknown keys inside `hypothesis`, `scope` and `outcome` are kept.

### 4.1 `hunts[]`

```json
{
  "id": "h-sched-tasks",
  "number": "H-002",
  "title": "Scheduled-task persistence on POS back-office servers",
  "status": "active",
  "stage": "hunt",
  "priority": "medium",
  "hunter": "Luis Ferreira",
  "dueDate": "2026-10-09",
  "cadence": "monthly",
  "fromTemplate": "tpl-sched-task-persist",
  "hypothesis": { "actor": "", "behavior": "", "location": "", "evidence": "" },
  "attack": ["T1053.005"],
  "scope": { "timeWindow": "", "assets": "", "dataSources": [] },
  "successCriteria": "",
  "references": [],
  "steps": [],
  "findings": [],
  "outcome": { "result": "open", "foundEvil": false, "summary": "" },
  "improvements": [],
  "activity": [],
  "created_at": "2026-09-24T08:00:00.000Z",
  "updated_at": "2026-09-25T10:20:00.000Z"
}
```

| Field | Type | Meaning |
|---|---|---|
| `number` | string | The citable hunt number, `H-` plus at least three digits. See §6.1. **Not generated on import**: a missing one stays blank. |
| `title` | string | Missing becomes `"Untitled hunt"`. |
| `status` | `"draft"` \| `"active"` \| `"complete"` \| `"archived"` | Missing becomes `draft`. See §6.2. |
| `stage` | `"scope"` \| `"hunt"` \| `"conclude"` | Where the hunt is in its lifecycle. Missing becomes `scope`. |
| `priority` | `"low"` \| `"medium"` \| `"high"` \| `"critical"` | Missing becomes `medium`. |
| `hunter` | string | The assignee. Free text, `""` for unassigned. |
| `dueDate` | `"YYYY-MM-DD"` or `""` | See §6.3. |
| `cadence` | `""` \| `"weekly"` \| `"biweekly"` \| `"monthly"` \| `"quarterly"` | How often this hunt recurs. `""` is one-off. A label only: the app does not create the next hunt. |
| `fromTemplate` | template id or `null` | The library template this hunt was started from, e.g. `"tpl-sched-task-persist"`. Informational; not checked. `null` for a hunt written from scratch. |
| `hypothesis` | object | See §4.2. |
| `attack` | array of ATT&CK technique ids | See §6.5. **Must be an array**: a single string is silently replaced by `[]`. |
| `scope` | object | See §4.2. |
| `successCriteria` | string | What "done" means for this hunt. |
| `references` | array of strings | URLs or citations. **Must be an array**: a string is silently replaced by `[]`. |
| `steps` | array | See §4.3. |
| `findings` | array | See §4.4. |
| `outcome` | object | See §4.2. |
| `improvements` | array | See §4.5. |
| `activity` | array | See §4.6. |
| `created_at`, `updated_at` | ISO 8601 datetime | `created_at` is the hunt's "Created" date in reports and drives the hunts-per-month chart. `updated_at` is shown on the hunt list. Both are kept exactly as written. |

Built-in library template ids, for `fromTemplate`:

| Id | Title |
|---|---|
| `tpl-sched-task-persist` | Persistence via Scheduled Tasks |
| `tpl-encoded-powershell` | Suspicious / Encoded PowerShell |
| `tpl-lsass-cred-access` | LSASS Credential Access |
| `tpl-service-install` | Suspicious Service Installation |
| `tpl-anomalous-rdp` | Anomalous RDP & Remote Logons |
| `tpl-lolbin-abuse` | LOLBin Abuse (rundll32 / mshta / regsvr32 / certutil) |
| `tpl-account-creation` | Suspicious Local Account Creation & Group Changes |
| `tpl-web-shell` | Web Shell on Internet-Facing Host |
| `tpl-c2-beaconing` | C2 Beaconing & DNS Tunneling |
| `tpl-lateral-smb` | Lateral Movement via SMB Admin Shares / PsExec |
| `tpl-oauth-consent` | Illicit Cloud OAuth Consent Grant |
| `tpl-data-exfil-cloud` | Data Staging & Exfiltration to Cloud Storage |

### 4.2 `hypothesis`, `scope` and `outcome`

These three are objects inside a hunt. Missing keys are filled with the
defaults shown; a `null` object becomes all defaults.

```json
"hypothesis": {
  "actor": "An adversary with code execution on a store server",
  "behavior": "is registering a scheduled task to re-run their payload across reboots",
  "location": "POS back-office servers in the 40 retail stores",
  "evidence": "task-creation events and svchost spawning unusual children"
}
```

The hypothesis follows the ABLE pattern: **A**ctor, **B**ehavior,
**L**ocation, **E**vidence. The report reads it as one sentence, *"We believe
&lt;actor&gt; &lt;behavior&gt; within &lt;location&gt;, observable through
&lt;evidence&gt;."*, so write `behavior` as a verb phrase that follows the
actor.

```json
"scope": {
  "timeWindow": "Last 30 days",
  "assets": "POS back-office servers (STORE-BO-*)",
  "dataSources": ["scheduled-tasks", "sysmon", "pos-agent-logs"]
}
```

| Field | Type | Meaning |
|---|---|---|
| `timeWindow` | string | Free text, e.g. `"Last 14 days"`. |
| `assets` | string | Free text: which hosts, accounts or tenants. |
| `dataSources` | array of data-source ids | Built-in (§6.4) or this program's custom ones (§4.7). |

```json
"outcome": { "result": "proven", "foundEvil": true, "summary": "Confirmed a download cradle on FS-02." }
```

| Field | Values |
|---|---|
| `result` | `"open"` (not yet concluded), `"proven"` (hypothesis proven, evil found), `"disproven"` (no evil found) or `"inconclusive"` (insufficient data). |
| `foundEvil` | boolean. Drives the "Evil found" count and the red mark on the ATT&CK coverage map. **A boolean, not a string.** |
| `summary` | string. The conclusion paragraph; line breaks are kept in the report. |

### 4.3 `steps[]`

The analysis steps, in the order they are worked.

```json
{
  "id": "s-st-1",
  "order": 0,
  "description": "List scheduled-task creations in the window",
  "dataSource": "scheduled-tasks",
  "query": "Security 4698 OR TaskScheduler/Operational 106, group by host, creator, task name",
  "queries": {
    "spl": "index=wineventlog EventCode=4698 | stats count by host, SubjectUserName, TaskName",
    "kql": "SecurityEvent | where EventID == 4698 | summarize count() by Computer, SubjectUserName, TaskName"
  },
  "status": "done",
  "result": "212 creations; 204 are the nightly POS sync task."
}
```

| Field | Type | Meaning |
|---|---|---|
| `order` | integer | Position, starting at 0. **Array order is display order**; keep `order` equal to the array index. The app rewrites it whenever a step is added, deleted or moved, and uses it when a hunt is saved to the library. |
| `description` | string | What this step does. |
| `dataSource` | data-source id or `""` | One source, built-in or custom. |
| `query` | string | The generic, tool-neutral query or pseudo-query. |
| `queries` | object | The same query in specific languages, keyed by `"spl"` (Splunk SPL), `"kql"` (KQL), `"eql"` (Elastic EQL) or `"osquery"`. Include only the ones you have; `{}` for none. Other keys are kept but never shown. |
| `status` | `"todo"` \| `"done"` \| `"blocked"` | Displayed as To do / Done / Blocked. A hunt's progress dots count `done`. |
| `result` | string | What the step turned up. `""` until it has run. |

### 4.4 `findings[]`

What the hunt turned up, each triaged.

```json
{
  "id": "f-st-1",
  "title": "Task 'OneDriveUpdate' on STORE-BO-17 runs from %TEMP%",
  "description": "Created by a store manager account that has never administered this server.",
  "evidence": "4698 at 2026-09-24 02:13 on STORE-BO-17",
  "attack": "T1053.005",
  "disposition": "suspicious",
  "escalated": false,
  "created_at": "2026-09-25T10:20:00.000Z"
}
```

| Field | Type | Meaning |
|---|---|---|
| `title` | string | One line. |
| `description`, `evidence` | string | Free text. |
| `attack` | string | **One** ATT&CK technique id, or `""`. Unlike the hunt's `attack`, this is a string, not an array. |
| `disposition` | `"benign"` \| `"suspicious"` \| `"malicious"` \| `"inconclusive"` | The triage verdict. Missing becomes `inconclusive`. |
| `escalated` | boolean | Handed to incident response. Shown as a flag on the finding. |
| `created_at` | ISO 8601 datetime | When it was recorded. |

### 4.5 `improvements[]`

What the hunt leaves behind for the security program.

```json
{
  "id": "i-ps-1",
  "type": "detection",
  "description": "Alert on encoded PowerShell spawned by the task scheduler on servers",
  "status": "done",
  "owner": "Detection engineering",
  "created_at": "2026-09-13T09:00:00.000Z"
}
```

| Field | Values |
|---|---|
| `type` | `"detection"` (new detection), `"visibility-gap"`, `"baseline"` (new baseline), `"runbook"` (runbook or playbook) or `"automation"`. Missing becomes `detection`. The dashboard's "Detections" count is the number of `detection` improvements. |
| `status` | `"open"` or `"done"`. Missing becomes `open`. |
| `owner` | string. A person or team, `""` for none. |

### 4.6 `activity[]`

An append-only log of what happened to the hunt, shown as the hunt's
**Activity log**. The app adds entries itself as the hunt is worked. A
generated hunt can carry `[]`; an edited one should keep its entries
untouched.

```json
{ "id": "a-st-2", "at": "2026-09-24T09:30:00.000Z", "type": "stage", "detail": "Stage → Hunt" }
```

| Field | Values |
|---|---|
| `at` | ISO 8601 datetime. |
| `type` | The app writes `created`, `stage`, `status`, `completed`, `step`, `finding`, `triage`, `improvement` and `outcome`. Shown as a tag; any string displays. |
| `detail` | Free text. |

Entries are shown in array order; write them oldest first, as the app does.
When the app adds an entry
and the log is longer than 250, it keeps only the newest 250. Entries are kept
exactly as written, including unknown keys.

### 4.7 `customDataSources[]` (on the program)

Data sources this environment has that the built-in list (§6.4) lacks.

```json
{
  "id": "pos-agent-logs",
  "label": "POS agent logs",
  "examples": "Vendor agent install and sync logs on each back-office server",
  "custom": true
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | string | Lower-case words joined by `-`. **Must not repeat a built-in id** or another custom one. |
| `label` | string | Shown wherever a data source is named. |
| `examples` | string | Hint text, e.g. an index name or event ids. |
| `custom` | `true` | Write it. |

A custom data source belongs to its program only. A hunt in another program
that names it shows the raw id instead of the label.

### 4.8 `customTemplates[]` (on the program)

Hunts saved to this program's library with **Save to library**, reusable as
the starting point for new hunts.

```json
{
  "id": "tpl-custom-encps-servers",
  "tier": "custom",
  "custom": true,
  "title": "Encoded PowerShell on servers",
  "attack": ["T1059.001", "T1027"],
  "priority": "high",
  "hypothesis": { "actor": "", "behavior": "", "location": "", "evidence": "" },
  "scope": { "timeWindow": "", "assets": "", "dataSources": [] },
  "successCriteria": "",
  "steps": [ { "description": "", "dataSource": "", "query": "" } ],
  "goodVsBad": "",
  "falsePositives": "",
  "references": [],
  "savedFrom": "H-001",
  "savedAt": "2026-09-13T09:20:00.000Z"
}
```

| Field | Meaning |
|---|---|
| `id` | Unique; must not repeat a built-in template id. The app writes `tpl-custom-…`. |
| `tier` | **`"custom"`.** The library groups templates by tier, and a template with any other tier appears under that group or nowhere. |
| `custom` | **`true`.** Without it, starting a hunt from this template fails with *"Unknown hunt template."* and the delete button is missing. |
| `title`, `attack`, `priority`, `hypothesis`, `scope`, `successCriteria`, `references` | As on a hunt (§4.1, §4.2). |
| `steps` | Array of `{ description, dataSource, query }`. A step may also carry `queries` as in §4.3. No `id`, `status` or `result`: those are made fresh when a hunt starts from it. |
| `goodVsBad`, `falsePositives` | Free-text guidance shown in the library. The app leaves them `""` when saving from a hunt. |
| `savedFrom` | The hunt number it was saved from, or `""`. Informational. |
| `savedAt` | ISO 8601 datetime. Informational. |

Saved templates are stored exactly as written.

### 4.9 `collectedSources` (on the program)

An array of data-source ids, built-in or custom, that this environment
actually collects. The **Gaps** view compares it against what the library's
hunts need and ranks the missing sources. `[]` means nothing has been
recorded yet, so every source reads as a gap.

---

## 5. Cross-references

| From | Field | To |
|---|---|---|
| envelope | `data.activeSlug` | a program's `slug` |
| hunt | `scope.dataSources[]` | a built-in data-source id (§6.4) or `customDataSources[].id` in the same program |
| step | `dataSource` | the same, or `""` |
| saved template | `scope.dataSources[]`, `steps[].dataSource` | the same |
| program | `collectedSources[]` | the same |
| hunt | `fromTemplate` | a built-in template id (§4.1) or `null`; not checked |
| saved template | `savedFrom` | a hunt `number`; not checked |

None of these are checked on import. An unknown data-source id is shown as its
raw id. There are no references between hunts, and nothing points across
programs.

---

## 6. Things that trip a generator

### 6.1 Hunt numbers and `hunt_seq`

- A hunt's `number` is `H-` plus the sequence number padded to three digits:
  `H-001` … `H-999`, then `H-1000`.
- `hunt_seq` on the program is the last number handed out. The next hunt the
  user creates gets `hunt_seq + 1`.
- **The importer never renumbers and never checks.** A missing `number` stays
  blank. If `hunt_seq` is lower than the highest number in use, the next new
  hunt repeats an existing number (with `hunt_seq: 0`, the next hunt is
  `H-001` again).
- So: number hunts in the order they were created, with no gaps and no
  duplicates, and set `hunt_seq` to the highest number.
- Numbers are per program. Two programs can each have an `H-001`.
- **Array order is display order**, and the app puts new hunts first. Write
  the newest hunt first to match.

### 6.2 The hunt lifecycle

`stage` is where the work is; `status` is where the hunt stands; `outcome` is
the answer. The app does not check that they agree, so write combinations that
make sense:

| State | `status` | `stage` | `outcome.result` | `outcome.foundEvil` |
|---|---|---|---|---|
| Being planned, not started | `draft` | `scope` | `open` | `false` |
| Being scoped | `active` | `scope` | `open` | `false` |
| Being worked | `active` | `hunt` | `open` | `false` |
| Being written up | `active` | `conclude` | any | as found |
| Finished, evil found | `complete` | `conclude` | `proven` | `true` |
| Finished, nothing found | `complete` | `conclude` | `disproven` | `false` |
| Finished, not enough data | `complete` | `conclude` | `inconclusive` | `false` |
| Parked | `archived` | any | any | as found |

- In the app, a hunt cannot be marked complete while its outcome is `open`.
  Don't write that combination.
- Hunts created in the app start as `active`; `draft` is for a hunt that is
  only an idea so far.
- The dashboard's "Active" count is hunts with `status: "active"`.
- `foundEvil: true` is what marks a technique red on the ATT&CK coverage map
  and counts toward "Evil found", whatever the `result` says.

### 6.3 Dates and times

- `dueDate` is exactly `YYYY-MM-DD`, or `""`. It is compared with the date on
  the user's own device: a hunt whose `dueDate` is before today, and whose
  status is not `complete` or `archived`, is marked **Overdue**.
- Every other timestamp (`created_at`, `updated_at`, `exportedAt`, a finding's
  or improvement's `created_at`, an activity entry's `at`, a template's
  `savedAt`) is a full ISO 8601 datetime in UTC, like
  `"2026-09-24T08:00:00.000Z"`.
- The hunts-per-month chart groups hunts by the first seven characters of
  `created_at`, so it must start `YYYY-MM`.

### 6.4 Built-in data-source ids

| Id | Label | Typical content |
|---|---|---|
| `win-security` | Windows Security log | 4624/4625 logon, 4688 process, 4720 user, 7045 service |
| `sysmon` | Sysmon | 1 process, 3 network, 7 image load, 11 file, 13 registry |
| `powershell` | PowerShell logs | 4103 module, 4104 script-block |
| `scheduled-tasks` | Scheduled Task events | 4698 created, 4702 updated, TaskScheduler/Operational |
| `edr` | EDR telemetry | process tree, command line, file/network events |
| `process` | Process creation | cross-platform exec + command line |
| `dns` | DNS query logs | resolver logs, Sysmon 22, passive DNS |
| `proxy` | Web proxy / HTTP | URL, user-agent, bytes, referrer |
| `firewall` | Firewall / NetFlow | allow/deny, 5-tuple, byte counts |
| `network` | Network metadata | Zeek conn/dns/http/ssl, PCAP |
| `auth` | Authentication | VPN, SSO, Kerberos, RADIUS |
| `cloud-audit` | Cloud audit | Entra ID / M365, AWS CloudTrail, GCP audit |
| `email` | Email gateway | sender, subject, attachments, links |
| `file-monitor` | File / object access | file shares, S3/Blob access, 4663 |
| `registry` | Registry | Run keys, services, Sysmon 12/13/14 |

Anything else must be declared in the program's `customDataSources`.

### 6.5 ATT&CK technique ids

- Write MITRE ATT&CK Enterprise technique ids in upper case: `T1059` for a
  technique, `T1059.001` for a sub-technique. The app's own picker accepts
  exactly that shape.
- A hunt's `attack` is an **array**; a finding's `attack` is a **single
  string**.
- The ATT&CK coverage view groups techniques by tactic using the part before
  the dot, so a sub-technique counts under its parent's tactic.
- Ids are not checked against a catalog on import; an unknown one is kept
  and shown with no technique name.

### 6.6 What the app derives

Nothing below is stored, so there is nothing to write for it: the dashboard
counts, the ATT&CK coverage map and its Navigator layer, the visibility gaps,
threat-actor coverage, overdue flags, progress dots, and the reports. They are
computed from the fields above each time they are shown.

---

## 7. Editing an existing export

The safest edit changes values and leaves structure alone.

**Preserve:**

- **Every `id`**: program `slug`, hunt, step, finding, improvement, activity,
  custom data source and saved template ids. **Merge Workspace** matches hunts,
  custom data sources and saved templates **by id**: a record whose id already
  exists in the target program is skipped, and one with a new id is added. An
  edited copy with changed ids merges as duplicates; a new record given an
  existing id is skipped as already present.
- **Hunt `number`s and `hunt_seq`.** People cite hunts by number. When you
  add a hunt, give it `hunt_seq + 1` and raise `hunt_seq`.
- **`created_at`** everywhere, and the **`activity`** log. They are the
  record of what happened.
- **Unknown keys on the program** and inside `hypothesis`, `scope` and
  `outcome`. They are kept, so an older or newer copy of the app can still read
  them.

**What the app does on import:**

- It **re-stamps `updated_at` on the program that opens first** (the one
  `activeSlug` names) with the time of the import. Every other timestamp is
  kept exactly.
- It rebuilds each hunt, step, finding and improvement from the fields in §4,
  filling defaults for missing ones and **dropping any other key**.
- It keeps the order of programs, hunts, steps, findings, improvements and
  activity entries exactly as written.
- It does not read `data.schema`, and it does not renumber, re-sort or
  validate anything.

**When you add a record:** give it a fresh, unique id, set `created_at` to
the current time, and, for a hunt, the next number. Put a new hunt first in
`hunts`. Put a new step at the end and set its `order` to its index.

**When you delete a step:** renumber the remaining steps' `order` to match
their new positions.

**Merging instead of replacing.** Right-clicking a program tab and choosing
**Merge Workspace…** reads a pack, previews what it would add, and brings in
the hunts, custom data sources and saved templates the target program does not
already have (by id). Merged hunts are **renumbered** onto the target
program's sequence. Nothing else in the target changes. Use it to fold a
teammate's copy back into yours.

---

## 8. Things that go wrong

| Mistake | What happens |
|---|---|
| Programs with no envelope | Refused: *"Import failed — Missing the puma envelope — not a .pumapack file."* |
| `puma.app` is not `"pumahunter"` | Refused: *"Import failed — That pack is from &lt;app&gt;. Open it there instead."* |
| No `data.workspaces` array | Refused: *"Import failed — No hunt programs in this pack."* |
| Truncated or hand-broken JSON | Refused: *"Import failed — Not valid JSON."* |
| `data.workspaces` is `[]` | Accepted: *"Imported 0 program(s) · 0 hunt(s)"*. Every existing program is replaced by nothing, and the app has no program to add hunts to until it is reloaded, when it creates an empty "My hunts". |
| A `null` entry in `data.workspaces` | The import stops part-way with no message and nothing changes. |
| `steps`, `findings` or `improvements` as a string | The import stops part-way with no message and nothing changes. |
| A hunt's `attack` or `references` as a single string | Silently replaced by `[]`. The technique or link is lost. |
| A program with no `slug` | Imported with no identity; the tab works until the next reload, when a slug is made from the name. Always write one. |
| Two programs with the same `slug` | Not detected. Anything that looks a program up by slug finds only the first. |
| A hunt with no `number` | Kept blank. Nothing numbers it. |
| `hunt_seq` below the highest hunt number | The next hunt the user creates repeats an existing number. |
| A hunt, step, finding or improvement with no `id` | Given a fresh random id. Harmless for a new pack, but an edited copy then merges as a duplicate. |
| An unknown key on a hunt, step, finding or improvement | Dropped. |
| Enum typo, e.g. `"In progress"` or `"Complete"` | Kept as it is. It shows blank and falls out of every count and filter. |
| `foundEvil: "false"` | A non-empty string counts as true. Write booleans. |
| `null` for `hypothesis`, `outcome`, `findings`, `improvements`, `steps` | Tolerated: replaced with the default. Write the real shape anyway. |
| `null` for `scope.dataSources` | Kept as `null`, displayed as no sources. Write `[]`. |
| A data-source id that is neither built-in nor declared on the program | Shown as the raw id; the Gaps view cannot credit it. |
| A saved template without `"tier": "custom"` and `"custom": true` | Missing from the program's library group, or it cannot start a hunt: *"Unknown hunt template."* |
| `status: "complete"` with `outcome.result: "open"` | Accepted, but it is a state the app itself never allows. |

---

## 9. Checklist before handing a pack over

A pack that passes all of these imports with the success message and nothing
renumbered, re-stamped (beyond the one `updated_at` in §7) or dropped.

**Structure**
- [ ] The envelope matches §2: `puma.app` is `"pumahunter"`, `data.schema`
      is `2`, and `data.workspaces` is a non-empty array.
- [ ] `data.activeSlug` names one of the programs.
- [ ] Every record has every field from §3 and §4, with no unknown keys on
      hunts, steps, findings or improvements.
- [ ] No `null` anywhere except a hunt's `fromTemplate`.
- [ ] Every array field is an array, including a hunt's `attack` and
      `references`.

**Identity and numbering**
- [ ] Program slugs are unique, lower-case and at most 40 characters.
- [ ] Ids are unique within each array.
- [ ] Hunt numbers are `H-001` upward with no gaps or duplicates, and each
      program's `hunt_seq` equals its highest number.
- [ ] Hunts are newest first; each step's `order` equals its index.

**Values**
- [ ] Every enum value is one of the exact ids in §4 and §6.
- [ ] `status`, `stage` and `outcome` agree (§6.2).
- [ ] Every data-source id is built-in (§6.4) or declared in the same
      program's `customDataSources`.
- [ ] ATT&CK ids are upper case (`T1059.001`); a finding's `attack` is one
      string.
- [ ] `dueDate` is `YYYY-MM-DD` or `""`; every other timestamp is a full ISO
      datetime.
- [ ] Every saved template has `"tier": "custom"` and `"custom": true`.

---

## 10. A complete example

One program with two hunts: a finished one that found evil and left two
improvements behind, and one in progress that uses a custom data source and
carries Splunk and KQL versions of a query. The program also has one saved
template and a visibility inventory. It imports with no warnings, and the app
says *"Imported 1 program(s) · 2 hunt(s)"*.

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumahunter",
    "appVersion": "generated",
    "format": 1,
    "exportedAt": "2026-09-28T09:00:00.000Z",
    "kind": "backup",
    "title": "PumaHunter backup"
  },
  "data": {
    "schema": 2,
    "workspaces": [
      {
        "slug": "northwind-q4",
        "name": "Northwind Retail Q4",
        "accent_color": "#4a9d5f",
        "hunter": "Dana Okafor",
        "hunts": [
          {
            "id": "h-sched-tasks",
            "number": "H-002",
            "title": "Scheduled-task persistence on POS back-office servers",
            "status": "active",
            "stage": "hunt",
            "priority": "medium",
            "hunter": "Luis Ferreira",
            "dueDate": "2026-10-09",
            "cadence": "monthly",
            "fromTemplate": "tpl-sched-task-persist",
            "hypothesis": {
              "actor": "An adversary with code execution on a store server",
              "behavior": "is registering a scheduled task to re-run their payload across reboots",
              "location": "POS back-office servers in the 40 retail stores",
              "evidence": "task-creation events and svchost spawning unusual children"
            },
            "attack": ["T1053.005"],
            "scope": {
              "timeWindow": "Last 30 days",
              "assets": "POS back-office servers (STORE-BO-*)",
              "dataSources": ["scheduled-tasks", "sysmon", "pos-agent-logs"]
            },
            "successCriteria": "Every scheduled task created in the window is attributable to a known admin or application, or escalated.",
            "references": ["https://attack.mitre.org/techniques/T1053/005/"],
            "steps": [
              {
                "id": "s-st-1",
                "order": 0,
                "description": "List scheduled-task creations in the window",
                "dataSource": "scheduled-tasks",
                "query": "Security 4698 OR TaskScheduler/Operational 106, group by host, creator, task name",
                "queries": {
                  "spl": "index=wineventlog EventCode=4698 host=STORE-BO-* | stats count by host, SubjectUserName, TaskName",
                  "kql": "SecurityEvent | where EventID == 4698 and Computer startswith \"STORE-BO-\" | summarize count() by Computer, SubjectUserName, TaskName"
                },
                "status": "done",
                "result": "212 creations; 204 are the nightly POS sync task from the vendor agent."
              },
              {
                "id": "s-st-2",
                "order": 1,
                "description": "Check the survivors against the POS agent's own install log",
                "dataSource": "pos-agent-logs",
                "query": "Agent install log: task names registered by the vendor installer, by host",
                "queries": {},
                "status": "todo",
                "result": ""
              }
            ],
            "findings": [
              {
                "id": "f-st-1",
                "title": "Task 'OneDriveUpdate' on STORE-BO-17 runs from %TEMP%",
                "description": "Created by a store manager account that has never administered this server. Action is a script in the user's temp folder.",
                "evidence": "4698 at 2026-09-24 02:13 on STORE-BO-17; action C:\\Users\\mgr17\\AppData\\Local\\Temp\\u.cmd",
                "attack": "T1053.005",
                "disposition": "suspicious",
                "escalated": false,
                "created_at": "2026-09-25T10:20:00.000Z"
              }
            ],
            "outcome": { "result": "open", "foundEvil": false, "summary": "" },
            "improvements": [],
            "activity": [
              { "id": "a-st-1", "at": "2026-09-24T08:00:00.000Z", "type": "created", "detail": "Created from library: Persistence via Scheduled Tasks" },
              { "id": "a-st-2", "at": "2026-09-24T09:30:00.000Z", "type": "stage", "detail": "Stage → Hunt" },
              { "id": "a-st-3", "at": "2026-09-25T10:20:00.000Z", "type": "finding", "detail": "Finding: Task 'OneDriveUpdate' on STORE-BO-17 runs from %TEMP% — Suspicious" }
            ],
            "created_at": "2026-09-24T08:00:00.000Z",
            "updated_at": "2026-09-25T10:20:00.000Z"
          },
          {
            "id": "h-enc-ps",
            "number": "H-001",
            "title": "Encoded PowerShell on file servers",
            "status": "complete",
            "stage": "conclude",
            "priority": "high",
            "hunter": "Dana Okafor",
            "dueDate": "",
            "cadence": "",
            "fromTemplate": null,
            "hypothesis": {
              "actor": "An adversary running tooling on a compromised file server",
              "behavior": "is launching PowerShell with encoded commands to evade logging",
              "location": "The three head-office file servers",
              "evidence": "command lines with -EncodedCommand and script-block logs"
            },
            "attack": ["T1059.001", "T1027"],
            "scope": {
              "timeWindow": "Last 7 days",
              "assets": "FS-01, FS-02, FS-03",
              "dataSources": ["powershell", "sysmon"]
            },
            "successCriteria": "Every encoded PowerShell invocation is explained by a known tool, or escalated.",
            "references": ["https://attack.mitre.org/techniques/T1059/001/"],
            "steps": [
              {
                "id": "s-ps-1",
                "order": 0,
                "description": "Find PowerShell launched with encoding or download flags",
                "dataSource": "sysmon",
                "query": "Sysmon 1 where Image ends powershell.exe and CommandLine matches -enc|FromBase64String|DownloadString",
                "queries": {},
                "status": "done",
                "result": "Two hits, both on FS-02, both at 03:00."
              },
              {
                "id": "s-ps-2",
                "order": 1,
                "description": "Decode the payloads and read what they do",
                "dataSource": "powershell",
                "query": "4104 script-block content for the two sessions",
                "queries": {},
                "status": "done",
                "result": "Decodes to a download cradle pulling a second stage from an unfamiliar host."
              }
            ],
            "findings": [
              {
                "id": "f-ps-1",
                "title": "Download cradle on FS-02",
                "description": "Nightly encoded PowerShell fetching a second stage over HTTPS.",
                "evidence": "Sysmon 1 and 4104 on FS-02, 2026-09-10 and 2026-09-11 at 03:00",
                "attack": "T1059.001",
                "disposition": "malicious",
                "escalated": true,
                "created_at": "2026-09-12T11:05:00.000Z"
              }
            ],
            "outcome": {
              "result": "proven",
              "foundEvil": true,
              "summary": "Confirmed a download cradle on FS-02. Escalated to incident response; host isolated."
            },
            "improvements": [
              {
                "id": "i-ps-1",
                "type": "detection",
                "description": "Alert on encoded PowerShell spawned by the task scheduler on servers",
                "status": "done",
                "owner": "Detection engineering",
                "created_at": "2026-09-13T09:00:00.000Z"
              },
              {
                "id": "i-ps-2",
                "type": "visibility-gap",
                "description": "Turn on script-block logging on the store back-office servers",
                "status": "open",
                "owner": "Platform team",
                "created_at": "2026-09-13T09:05:00.000Z"
              }
            ],
            "activity": [
              { "id": "a-ps-1", "at": "2026-09-10T09:00:00.000Z", "type": "created", "detail": "Hunt created" },
              { "id": "a-ps-2", "at": "2026-09-12T11:05:00.000Z", "type": "finding", "detail": "Finding: Download cradle on FS-02 — Malicious" },
              { "id": "a-ps-3", "at": "2026-09-13T09:10:00.000Z", "type": "outcome", "detail": "Outcome → Hypothesis proven — evil found" },
              { "id": "a-ps-4", "at": "2026-09-13T09:12:00.000Z", "type": "completed", "detail": "Hunt marked complete" }
            ],
            "created_at": "2026-09-10T09:00:00.000Z",
            "updated_at": "2026-09-13T09:12:00.000Z"
          }
        ],
        "hunt_seq": 2,
        "customDataSources": [
          {
            "id": "pos-agent-logs",
            "label": "POS agent logs",
            "examples": "Vendor agent install and sync logs on each back-office server",
            "custom": true
          }
        ],
        "customTemplates": [
          {
            "id": "tpl-custom-encps-servers",
            "tier": "custom",
            "custom": true,
            "title": "Encoded PowerShell on servers",
            "attack": ["T1059.001", "T1027"],
            "priority": "high",
            "hypothesis": {
              "actor": "An adversary running tooling on a compromised server",
              "behavior": "is launching PowerShell with encoded commands to evade logging",
              "location": "Windows servers",
              "evidence": "command lines with -EncodedCommand and script-block logs"
            },
            "scope": { "timeWindow": "Last 7 days", "assets": "All Windows servers", "dataSources": ["powershell", "sysmon"] },
            "successCriteria": "Every encoded PowerShell invocation is explained by a known tool, or escalated.",
            "steps": [
              { "description": "Find PowerShell launched with encoding or download flags", "dataSource": "sysmon", "query": "Sysmon 1 where Image ends powershell.exe and CommandLine matches -enc|FromBase64String|DownloadString" },
              { "description": "Decode the payloads and read what they do", "dataSource": "powershell", "query": "4104 script-block content for each session" }
            ],
            "goodVsBad": "",
            "falsePositives": "",
            "references": ["https://attack.mitre.org/techniques/T1059/001/"],
            "savedFrom": "H-001",
            "savedAt": "2026-09-13T09:20:00.000Z"
          }
        ],
        "collectedSources": ["win-security", "sysmon", "powershell", "scheduled-tasks", "edr", "pos-agent-logs"],
        "created_at": "2026-09-10T08:30:00.000Z",
        "updated_at": "2026-09-25T10:20:00.000Z"
      }
    ],
    "activeSlug": "northwind-q4"
  }
}
```

What the app shows from this, as a check on your own reasoning. These were
read back from the app after importing this exact file:

- Every field is stored exactly as written, in the same order, except the
  program's `updated_at`, which becomes the time of the import.
- Exporting straight away gives the same file back, apart from that
  `updated_at` and the envelope's `appVersion` and `exportedAt`.
- The dashboard reads 2 hunts, 1 active, 1 with evil found and 1 detection.
- `H-002` shows as monthly, due 2026-10-09, with one of its two steps done
  and a suspicious finding. Its second step names **POS agent logs**, the custom
  source.
- On the ATT&CK coverage map, T1059.001 and T1027 are marked as evil found;
  T1053.005 is hunted and still open.
- The library's *Custom · this program* group holds "Encoded PowerShell on
  servers".
- The next hunt the user creates in this program is `H-003`.
