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

This document describes PumaCase's `.pumapack` files in enough detail to
**edit an export** or **generate one from scratch** so that it imports
cleanly: 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. PumaCase imports it in either of two
ways:

- from the topbar **Import** button, then **Case data / backup…**;
- by dropping the file anywhere on the window.

**Import replaces everything.** The boards in the file become the only
boards in the app. Nothing is merged or added alongside what is already there.
If the app already holds at least one case, the user is first asked
*"Replace current data?"* and chooses **Cancel**, **Replace**, or **Export
current, then import**. On an empty app the file is imported straight away.

When it succeeds the app says *"Imported 1 board(s) · 2 case(s)"*, with the
counts of what the file contained.

---

## 1. The short version

If you only read one section, read this one.

1. Wrap the data in the envelope from §3. `puma.app` must be `"pumacase"` and
   `data.workspaces` must be a non-empty array of boards.
2. Write **every field** of every record, using the shapes in §4 to §6. Use
   `""`, `[]`, `false`, `0` or `null` exactly where this document shows them.
3. **Never put `null` inside an array.** A `null` task or evidence item aborts
   the whole import with no message.
4. Give every board a unique `slug` (lower-case `a-z`, `0-9` and `-`) and set
   `data.activeSlug` to one of them.
5. Use the exact enum ids from §6. Severity, status and TLP are corrected if
   wrong; every other enum is kept as written and silently misbehaves.
6. Number cases `IR-<year>-<NNN>` and set the board's `case_seq` to the highest
   `NNN` used. Number tasks `tid` 1, 2, 3… per case and set the case's
   `task_seq` to the highest. See §8.
7. Every `category` on a case should be the `id` of a category on the same
   board. See §7.
8. Timestamps are full ISO 8601 UTC datetimes such as
   `"2026-09-21T08:40:00.000Z"`.
9. When editing an export, **keep every `id`, `sha256`, custody event and
   timestamp exactly as it is**. Evidence file bytes are not in the
   `.pumapack`; they are matched to records by `sha256`. See §9.
10. Check the result against §12. §13 is a complete example you can copy.

---

## 2. The files PumaCase writes

PumaCase writes four kinds of file. Only the first two are `.pumapack` data
that Import reads as boards.

| File | How it is made | What it holds | Import accepts it? |
|---|---|---|---|
| **Full backup** | Topbar **Save** button, or Cmd/Ctrl+S. Saved as `pumacase-backup.json`. The **Export current, then import** choice in the replace dialog writes the same content as `pumacase-backup-YYYY-MM-DD.pumapack`. | Every board, with `"kind": "backup"`, plus the active board and the accent color. | Yes. **This is the file to hand an AI.** |
| **Board pack** | Right-click a board tab, **Export board**, **Board data (.pumapack)**. Saved as `<board-slug>.pumapack`. | One board, with `"kind": "board"`. No accent. | Yes, and like a full backup it **replaces** all boards. |
| **Evidence bundle** | Right-click a board tab, **Export board**, **Evidence bundle (.pumaevd)**. Saved as `<board-slug>-evidence.pumaevd`. | The raw bytes of that board's evidence files. Binary, not JSON. | Yes, from **Import**, **Evidence bundle…**, or by dropping it on the window. See §9.3. |
| **Timeline for PumaLogger** | A case's Timeline tab, **Export**, **To PumaLogger (.pumapack)**. Saved as `<case-number>-timeline.pumapack`. | One case's timeline as PumaLogger log entries. | **No.** PumaCase refuses it with *"Import failed — no case boards in this pack."* Open it in PumaLogger. |

PumaCase does not import other apps' packs. A pack whose `puma.app` is not
`"pumacase"` is refused with *"That pack is from pumaplanner. Open it there
instead."* (naming whichever app wrote it).

A complete backup of a browser is two files: the full backup and the evidence
bundle of each board that has evidence files. The full backup alone rebuilds
every record, including each file's SHA-256, but not the file contents.

---

## 3. The envelope

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumacase",
    "appVersion": "generated",
    "format": 1,
    "exportedAt": "2026-09-22T12:00:00.000Z",
    "kind": "backup",
    "title": "PumaCase backup"
  },
  "data": {
    "workspaces": [ { "...one board, see §4..." } ],
    "activeSlug": "soc-cases",
    "accent": null
  }
}
```

| Key | Value | Notes |
|---|---|---|
| `$schema` | `"https://pumaworx.dev/pumapack/v1"` | Not checked on import, but write it. |
| `puma` | object | **Required.** Without it the file is refused. |
| `puma.app` | `"pumacase"` | **Required, exact.** |
| `puma.appVersion` | any string | Informational. The app writes a short build id, or `"dev"`. |
| `puma.format` | `1` | The envelope version. Not checked. |
| `puma.exportedAt` | ISO 8601 datetime | Informational. |
| `puma.kind` | `"backup"` or `"board"` | Informational. Import treats both the same way. |
| `puma.title` | string | Informational. |
| `data.workspaces` | array of boards | **Required, non-empty.** |
| `data.activeSlug` | a board `slug` | The board shown after import. If it matches no board, the first board is shown. |
| `data.accent` | `null`, `"#rgb"` or `"#rrggbb"` | The app's accent color. `null` leaves the current accent alone. A full backup writes whatever the user picked, or `null`. |
| `attachments` | object, optional | Evidence file bytes as base64, at the **top level** beside `data`. Never written by the app's exports, but still read. See §9.2. |

The schema number (1) is not written into the file. A pack from any current
build of PumaCase is schema 1.

What the importer requires, and what it says when it refuses:

| Problem | Message |
|---|---|
| The file is not valid JSON | *"That file isn’t a recognised .pumapack / JSON backup (CSV and TSV exports can’t be re-imported)."* |
| No `puma` object (for example a bare `{ "workspaces": [...] }`) | *"Not a .pumapack file — missing the puma envelope."* |
| `puma.app` is not `"pumacase"` | *"That pack is from <app>. Open it there instead."* |
| `data.workspaces` is missing, not an array, or empty | *"Import failed — no case boards in this pack."* |

A refused file changes nothing. Every other key in `data` is ignored, with one
exception: an old pack may carry `data.playbooks` (`{ "categories": [...],
"sla": {...} }`), which is used for any board that has no categories or SLA of
its own. Don't write it; put categories and SLA on each board.

---

## 4. The board (`data.workspaces[]`)

A board is a tab in the app, with its own cases, categories and SLA targets.

```json
{
  "id": "ws-soc",
  "slug": "soc-cases",
  "name": "SOC cases",
  "accent_color": "#5b8af0",
  "created_at": "2026-09-01T09:00:00.000Z",
  "updated_at": "2026-09-22T11:30:00.000Z",
  "case_seq": 2,
  "categories": [ ],
  "sla": { },
  "cases": [ ]
}
```

| Field | Type | Notes |
|---|---|---|
| `id` | string | Kept as written. Make it unique across boards. |
| `slug` | string | **The board's identity.** Lower-case `a-z`, `0-9` and `-`, at most 40 characters, unique within the file. Kept as written: import does not check or repair it. |
| `name` | string | Shown on the tab. |
| `accent_color` | `"#rrggbb"` | The tab's color dot. Missing or empty becomes `"#e0608c"`. |
| `created_at`, `updated_at` | ISO datetime | Kept as written. The app moves `updated_at` whenever a case on the board changes. |
| `case_seq` | integer | The highest case number handed out on this board. See §8.1. If it is not a number, it is set to the number of cases. |
| `categories` | array | See §4.1. |
| `sla` | object | See §4.2. |
| `cases` | array of cases | See §5. `[]` for an empty board. |

Unknown keys on a board are kept and written back out on export.

### 4.1 `categories[]`

The board's incident categories, each with a playbook of starter tasks.

```json
{ "id": "phishing", "label": "Phishing / BEC", "playbook": [
  { "id": "pt-ph1", "title": "Confirm the report and pull the email", "group": "triage" }
] }
```

| Field | Type | Notes |
|---|---|---|
| `id` | string | Cases point at this. Missing gets a new generated id. |
| `label` | string | Shown in the category picker and on cases. Missing falls back to the `id`. |
| `playbook` | array of `{id, title, group}` | Task templates. `group` is a task group id from §6.6 (missing becomes `"triage"`). An item with no `id` gets a new generated one. |

- **`[]` or a missing `categories` gives the board the 13 built-in
  categories** and their built-in playbooks, with newly generated playbook
  ids. The built-in category ids are `malware`, `ransomware`, `phishing`,
  `unauthorized-access`, `data-breach`, `denial-of-service`,
  `insider-threat`, `lost-stolen`, `social-engineering`, `web-compromise`,
  `policy-violation`, `reconnaissance` and `other`.
- A non-empty array replaces them entirely. Write every category the board's
  cases use.
- Categories and playbook items are rebuilt field by field on import: **any
  other key on them is dropped.**
- Playbooks are only applied when a case is created in the app, or when the
  user presses **+ From playbook**. Importing never adds tasks to a case.

### 4.2 `sla`

Service-level targets in **hours**, per severity.

```json
{
  "critical": { "ack": 1,  "resolve": 24 },
  "high":     { "ack": 4,  "resolve": 72 },
  "medium":   { "ack": 24, "resolve": 168 },
  "low":      { "ack": 72, "resolve": 336 }
}
```

- `ack` is the time to first response; `resolve` is the time to close. Both
  count from the case's `detected_at`.
- The values above are the defaults. Each number is rounded to a whole hour;
  anything that is not a positive number falls back to the default for that
  cell.
- Exactly these four keys are kept. Any other key is dropped. A missing `sla`
  gives the defaults.

---

## 5. The case (`cases[]`)

```json
{
  "id": "case-inv4471",
  "number": "IR-2026-001",
  "title": "Invoice-themed phishing to finance team",
  "description": "",
  "severity": "high",
  "status": "analysis",
  "tlp": "amber",
  "category": "phishing",
  "assignee": "Priya Shah",
  "reporter": "Jordan Lee",
  "tags": ["phishing", "finance"],
  "detected_at": "2026-09-21T08:40:00.000Z",
  "created_at": "2026-09-21T09:05:00.000Z",
  "updated_at": "2026-09-22T11:30:00.000Z",
  "closed_at": null,
  "task_seq": 3,
  "tasks": [ ],
  "observables": [ ],
  "evidence": [ ],
  "timeline": [ ],
  "notes": [ ]
}
```

| Field | Type | Notes |
|---|---|---|
| `id` | string | Unique on the board. Kept as written. |
| `number` | string | The human case number, `IR-<year>-<NNN>`. Kept as written. See §8.1. |
| `title` | string | The case title. |
| `description` | string | The Overview **Summary**. Plain text; line breaks are kept. |
| `severity` | enum, §6.1 | Unknown values become `"medium"`. |
| `status` | enum, §6.2 | Unknown values become `"new"`. |
| `tlp` | enum, §6.3 | Unknown values become `"amber"`. |
| `category` | category id | Should match a category on this board (§4.1). Not checked; an unknown id is shown as the raw id. |
| `assignee`, `reporter` | string | Free-text names. `assignee` is also the default "by" name for custody events and notes the user adds later. |
| `tags` | array of strings | `[]` for none. |
| `detected_at` | ISO datetime | When the incident was detected. **The SLA clocks start here.** Missing becomes `created_at`. |
| `created_at` | ISO datetime | When the case was opened. |
| `updated_at` | ISO datetime | Last change. Drives "recently updated" ordering. |
| `closed_at` | ISO datetime or `null` | Set when `status` is `"closed"`, otherwise `null`. See §8.3. |
| `task_seq` | integer | The highest task `tid` handed out on this case. See §8.2. |
| `tasks`, `observables`, `evidence`, `timeline`, `notes` | arrays | See §6. `[]` when empty. A missing or non-array value becomes `[]`. |

Unknown keys on a case are kept and written back out on export.

---

## 6. Records inside a case

### Conventions for every record

- **`id`** is any non-empty string, unique within its array. The app
  generates ids like `task-mulyw8n2bz0kf`; short readable ids such as
  `task-a1` work just as well. Ids are kept exactly as written.
- **Timestamps** are ISO 8601 datetimes in UTC, like
  `"2026-09-21T08:40:00.000Z"`. Anything `new Date()` cannot read is shown as
  `—` and breaks the SLA arithmetic.
- **Enums other than severity, status and TLP are not validated.** A
  misspelled value is kept as written, shown as its raw id, and falls out of
  the logic that depends on it. Use the exact ids below.
- **Unknown keys on these records survive** import and export, with no
  effect.

### 6.1 Severity (`case.severity`)

| Id | Shown as | Default SLA (ack / resolve) |
|---|---|---|
| `low` | Low | 72h / 336h |
| `medium` | Medium | 24h / 168h |
| `high` | High | 4h / 72h |
| `critical` | Critical | 1h / 24h |

### 6.2 Case status (`case.status`)

The working status. The incident-response phase shown in the app is derived
from it and never stored.

| Id | Shown as | Derived phase |
|---|---|---|
| `new` | New | Detection & Analysis |
| `analysis` | Analysis | Detection & Analysis |
| `containment` | Containment | Containment, Eradication & Recovery |
| `eradication` | Eradication | Containment, Eradication & Recovery |
| `recovery` | Recovery | Containment, Eradication & Recovery |
| `closed` | Closed | Post-Incident Activity |

### 6.3 TLP (`case.tlp`, `observable.tlp`)

| Id | Shown as |
|---|---|
| `clear` | CLEAR |
| `green` | GREEN |
| `amber` | AMBER |
| `amber-strict` | AMBER+STRICT |
| `red` | RED |

On a case an unknown TLP becomes `"amber"`. On an observable it is **not**
corrected.

### 6.4 `tasks[]`

```json
{
  "id": "task-a2", "tid": 2,
  "title": "Identify all recipients and who interacted",
  "status": "in-progress", "group": "analysis",
  "assignee": "Priya Shah", "due_at": "2026-09-23T12:00:00.000Z",
  "summary": "",
  "notes": [ { "id": "tn-a2-1", "at": "2026-09-22T09:15:00.000Z", "text": "Message trace shows 14 recipients; 3 clicked." } ],
  "created_at": "2026-09-21T09:05:00.000Z", "updated_at": "2026-09-22T09:15:00.000Z",
  "completed_at": null
}
```

| Field | Type | Notes |
|---|---|---|
| `tid` | integer ≥ 1 | The task's short number within the case, shown as `T01`, `T02`. See §8.2. |
| `title` | string | The task. |
| `status` | `"todo"` \| `"in-progress"` \| `"blocked"` \| `"done"` | Shown as To do / In progress / Blocked / Done. Note the hyphen in `in-progress`. |
| `group` | enum, §6.6 | The incident-response phase the task belongs to. **A task whose `group` is not one of the §6.6 ids is not shown on the Tasks tab at all**, but still counts in the case's progress. |
| `assignee` | string | A name, or `""`. |
| `due_at` | ISO datetime or `null` | See §8.4. A task that is not `done` is flagged overdue once this instant has passed. |
| `summary` | string | Free-text details. |
| `notes` | array of `{id, at, text}` | Append-only notes, oldest first. `[]` for none. Write an array, never a string. |
| `created_at`, `updated_at` | ISO datetime | |
| `completed_at` | ISO datetime or `null` | When the task was completed. Set it for `"done"` tasks; `null` otherwise. |

Array order is display order within each group.

### 6.5 `observables[]`

```json
{
  "id": "obs-a2", "type": "ip", "value": "203.0.113.45",
  "tlp": "amber", "is_ioc": true, "sightings": 2,
  "notes": "Sending mail server", "tags": ["smtp"],
  "created_at": "2026-09-21T09:20:00.000Z"
}
```

| Field | Type | Notes |
|---|---|---|
| `type` | enum, below | What kind of indicator. |
| `value` | string | The indicator itself, refanged (`203.0.113.45`, not `203[.]0[.]113[.]45`). Not validated. |
| `tlp` | enum, §6.3 | |
| `is_ioc` | boolean | `true` marks an indicator of compromise; `false` is a plain sighting (the reporting user's address, say). |
| `sightings` | integer ≥ 1 | How many times it has been seen. |
| `notes` | string | A short note. |
| `tags` | array of strings | `[]` for none. |
| `created_at` | ISO datetime | |

Observable `type` ids: `ip`, `domain`, `url`, `port`, `hash-md5`,
`hash-sha1`, `hash-sha256`, `email`, `filename`, `filepath`, `registry`,
`host`, `user-account`, `cve`, `mac`, `user-agent`, `mutex`, `other`.

### 6.6 Task groups (`task.group`, playbook `group`)

| Id | Shown as |
|---|---|
| `triage` | Triage |
| `analysis` | Analysis |
| `containment` | Containment |
| `eradication` | Eradication |
| `recovery` | Recovery |
| `communications` | Communications |
| `other` | Other |

### 6.7 `evidence[]`

Evidence comes in three kinds that share one record shape. Every field is
present on every kind; the ones a kind does not use are `""` or `0`.

```json
{
  "id": "ev-a1", "kind": "file",
  "name": "phishing-headers.txt", "filename": "phishing-headers.txt",
  "mime": "text/plain", "size": 269,
  "sha256": "184f9753e68c7598c3c460c940835c1eb48244e02db170daa521734137b6be9f",
  "url": "", "body": "", "source": "Reporter's mailbox",
  "acquired_by": "Priya Shah", "acquired_at": "2026-09-21T09:20:00.000Z",
  "description": "Header block of the reported message",
  "integrity": "unverified",
  "custody": [ { "...see §6.8..." } ],
  "created_at": "2026-09-21T09:20:00.000Z"
}
```

| Field | `file` | `link` | `note` |
|---|---|---|---|
| `name` | display name | the link's label | the note's title |
| `filename` | original file name | `""` | `""` |
| `mime` | MIME type, e.g. `"image/png"` | `""` | `""` |
| `size` | size in bytes | `0` | `0` |
| `sha256` | **64 lower-case hex digits**; the key to the bytes (§9) | `""` | `""` |
| `url` | `""` | the URL | `""` |
| `body` | `""` | `""` | the note text (plain text, line breaks kept) |
| `integrity` | `"unverified"`, `"verified"` or `"tampered"` | `"n/a"` | `"n/a"` |

Fields common to all three:

| Field | Type | Notes |
|---|---|---|
| `kind` | `"file"` \| `"link"` \| `"note"` | |
| `source` | string | Where it came from. |
| `acquired_by` | string | Who collected it. |
| `acquired_at` | ISO datetime | When it was collected. |
| `description` | string | A longer description. |
| `custody` | array | See §6.8. `[]` is allowed, but the app always starts one with an `acquired` event. |
| `created_at` | ISO datetime | |

- **`integrity`** records the result of the last **Verify**:
  - `"unverified"`: nobody has re-checked the stored bytes since the file was
    added. This is what a newly added file has.
  - `"verified"`: the stored bytes were re-hashed and matched `sha256`.
  - `"tampered"`: they were re-hashed and did not match.
  - `"n/a"`: links and notes, which have no bytes.

  Import keeps `integrity` exactly as written and does not re-check it. It
  changes only when someone presses **Verify** in the app. A missing value
  becomes `"unverified"`.
- Link URLs are only clickable when they start with `http:`, `https:`,
  `mailto:`, `tel:` or `ftp:`. Any other scheme is shown as plain text.
- Array order is display order.

### 6.8 `custody[]` (on each evidence item)

```json
{ "id": "coc-a2", "action": "transferred", "actor": "Priya Shah",
  "from": "", "to": "Email security team",
  "note": "Shared for sender blocking", "at": "2026-09-22T08:30:00.000Z" }
```

| Field | Type | Notes |
|---|---|---|
| `action` | `"acquired"` \| `"accessed"` \| `"transferred"` \| `"verified"` \| `"exported"` \| `"returned"` | |
| `actor` | string | Who did it. |
| `from` | string | Stored, not shown. `""` is normal. |
| `to` | string | For a transfer, who received it. |
| `note` | string | Reason or context. |
| `at` | ISO datetime | When. |

**Array order is display order, and the app shows it as a chronological
list.** Write the events oldest first. The app only ever appends; there is no
way in the app to edit or delete a single custody event. It appends one itself on
**Verify** (`verified` on a match, `accessed` on a mismatch) and on
**Download** (`exported`).

### 6.9 `timeline[]`

```json
{ "id": "tl-a6", "kind": "finding", "title": "Three recipients clicked the link",
  "detail": "Proxy logs show visits from three finance workstations.", "actor": "Priya Shah",
  "at": "2026-09-22T09:10:00.000Z", "system": false,
  "created_at": "2026-09-22T09:12:00.000Z", "updated_at": "2026-09-22T09:40:00.000Z" }
```

| Field | Type | Notes |
|---|---|---|
| `kind` | `"detection"` \| `"analysis"` \| `"action"` \| `"containment"` \| `"communication"` \| `"finding"` \| `"note"` \| `"system"` | Shown as Detection / Analysis / Action taken / Containment / Communication / Finding / Note / System. |
| `title` | string | One line. |
| `detail` | string | Plain text; line breaks are kept. |
| `actor` | string | Who did or reported it. |
| `at` | ISO datetime | **When it happened.** The timeline is sorted by this, newest first, so array order does not matter. |
| `system` | boolean | `true` for events the app writes itself. See below. |
| `created_at` | ISO datetime | When the entry was written. |
| `updated_at` | ISO datetime or `""` | `""` for an unedited event. **A non-empty value shows "· edited <when>"** beside the event. |

**System events** (`"kind": "system", "system": true`) are the app's own
record of what happened to the case. The app shows them with no edit or
delete buttons. It writes them with these titles:

| When | `title` | `detail` |
|---|---|---|
| Case created | `Case opened` | `""` |
| Status changed | `Status → Analysis` (the new status label) | `Was New.` (the old status label) |
| Task completed | `IR-2026-001-T01 Completed: <task title>` | `""` |
| Evidence added | `Evidence added: <name>` | for a file, ``SHA-256 `<hash>` ``; otherwise `""` |

A generated pack does not need them, but an export will contain them. Leave
them in place when editing.

### 6.10 `notes[]` (case notes)

```json
{ "id": "note-a1", "body": "Check whether any of the three clickers reused the password elsewhere.",
  "author": "Priya Shah", "created_at": "2026-09-21T11:10:00.000Z", "updated_at": "2026-09-22T09:45:00.000Z" }
```

The **Notes** tab edits the **first** entry's `body`. Further entries are kept
and exported but not shown. `[]` means the tab is empty. Notes are left out of
the incident report exports.

---

## 7. Cross-references

References stay within one board.

| From | Field | To |
|---|---|---|
| `data` | `activeSlug` | a board's `slug` |
| case | `category` | `categories[].id` on the same board |
| evidence (`file`) | `sha256` | the key of the bytes in this browser (§9) |
| `attachments` | each key | an evidence record's `sha256` |

Nothing else links records together. Names (`assignee`, `actor`,
`acquired_by`, `author`) are free text with no roster behind them.

---

## 8. Numbering, dates and derived values

### 8.1 Case numbers and `case_seq`

- A case number is `IR-` + a four-digit year + `-` + a number padded to at
  least three digits: `IR-2026-001`, `IR-2026-042`, `IR-2026-1000`.
- When the user creates a case, the app adds 1 to the board's `case_seq` and
  uses the **current year**. The counter never resets with the year.
- Import keeps `number` and `case_seq` exactly as written. It does not check
  for gaps or duplicates.
- So set `case_seq` to the highest number used on the board. Otherwise the
  next case the user creates can repeat an existing number.

### 8.2 Task numbers (`tid`) and `task_seq`

- Each task has a `tid` (1, 2, 3…) unique within its case, shown as `T01`,
  `T02`. It is stable: deleting or reordering tasks never renumbers the rest.
- `task_seq` is the highest `tid` handed out on the case.
- On import:
  - `task_seq` is raised to the highest `tid` present if it is lower;
  - a task with no numeric `tid` is given the next number, in array order.
- Duplicate `tid`s are not detected. Don't write them.

### 8.3 SLA, age and closure

These are computed every time they are shown, and never stored:

- **Resolve SLA:** due at `detected_at` + the board's `resolve` hours for the
  case's severity. An open case shows the time left, is marked as at risk in
  the last quarter of the window, and reads *"Overdue by …"* after it. A
  closed case reads *"Met"* or *"Missed"*, comparing `closed_at` with the deadline.
- **Acknowledge target:** `detected_at` + `ack` hours, shown on the Overview.
- **Age:** from `detected_at` to now, or to `closed_at` once closed.

Because the clocks run from the real current time, an open case in a
generated pack will read as overdue once its window has passed. That is
expected.

- For `"status": "closed"`, set `closed_at`. If it is `null`, the app uses
  `updated_at` in its place.
- For any other status, `closed_at` should be `null`.

### 8.4 Dates

- Every timestamp is a full ISO 8601 datetime in UTC with a `Z`, as the app
  writes them. The app shows them in the viewer's local time.
- `due_at` on a task is an instant, not a bare date. The app stores the
  start of the chosen day in the user's own time zone. For a generated pack,
  **noon UTC** on the due day (`"2026-09-23T12:00:00.000Z"`) shows the same
  calendar day to almost every reader.

### 8.5 Ordering

| Array | Order that matters |
|---|---|
| `workspaces` | Tab order. |
| `cases` | None. The views sort by their own columns. |
| `tasks` | Display order within each task group. |
| `observables`, `evidence` | Display order. |
| `custody` | Display order, shown as oldest first. Write it chronologically. |
| `timeline` | None. Sorted by `at`. |
| `notes` | Only the first entry is shown. |

---

## 9. Evidence bytes and hashes

The contents of evidence **files** never live in the case records. They are
stored separately in this browser, **keyed by their SHA-256**. A file
evidence record finds its bytes by looking up its own `sha256`. This is what
**Download**, **Verify** and image thumbnails use.

When a file is added in the app, the app reads it, computes its SHA-256, and
stores the bytes under that hash. It rejects the same file being added twice
to one case.

### 9.1 What import does with hashes

- A `.pumapack` import **does not read, check or recompute** any `sha256` on an
  evidence record. It is stored exactly as written, along with `integrity`.
- If the bytes for a record's `sha256` are not in the browser, the record
  still shows normally. Pressing **Download** says *"Evidence bytes not found
  in this browser."*, and pressing **Verify** says *"Evidence bytes are not in
  this browser — import the .pumaevd bundle, then re-verify."* and leaves
  `integrity` unchanged.
- Bytes arrive by one of two routes, and **both re-hash the bytes before
  storing them**. Bytes whose SHA-256 does not equal the hash they are filed
  under are thrown away.

There are no device ids in a pack. Nothing in the file is tied to the browser
or machine that wrote it.

### 9.2 The `attachments` map (bytes inside the `.pumapack`)

A pack may carry file contents as base64 in a top-level `attachments` object,
beside `data`. The app's own exports never write it, but import still reads
it, so it is the only way to put evidence bytes in a single JSON file.

```json
"attachments": {
  "184f9753e68c7598c3c460c940835c1eb48244e02db170daa521734137b6be9f": {
    "mime": "text/plain",
    "b64": "UmV0dXJuLVBhdGg6IDxi...standard base64 of the whole file..."
  }
}
```

- The key is the file's SHA-256 in lower-case hex, and must equal the
  `sha256` of the evidence record it belongs to.
- `b64` is standard base64 (with `+` and `/`, padded), with no line breaks and
  no `data:` prefix.
- `mime` becomes the stored file's type.
- On import each entry is decoded and re-hashed. If the digest matches the
  key, the bytes are stored; if bytes for that hash are already stored, the
  entry is skipped.
- If the digest does not match, the entry is not stored, and the app calls
  *"Integrity mismatch — 1 attachment did not match the declared hash and was
  NOT imported."* The boards are still imported, and today that warning is
  immediately replaced on screen by the success message, so the user usually
  does not see it. The record then behaves as if its bytes were never
  supplied.
- **Only write `attachments` if you can compute SHA-256 exactly** over the
  exact bytes you encode. If you cannot, describe the material as a `note` or
  `link` evidence item instead, which needs no hash.

### 9.3 The `.pumaevd` evidence bundle

The evidence bundle is a binary file. Treat it as opaque: hand it back to the
app unchanged, and never convert it to text. For reference, it is laid out
as:

1. the 7 ASCII bytes `PUMAEVD`, then one byte `0x01`;
2. a 4-byte little-endian unsigned integer *N*;
3. *N* bytes of UTF-8 JSON:
   `{"app":"pumacase","v":1,"exportedAt":"…","count":2,"entries":[{"sha":"<hex>","mime":"<type>","size":<bytes>},…]}`;
4. the file contents, back to back, in `entries` order, each exactly `size`
   bytes.

Importing it (from **Import**, **Evidence bundle…**, or by dropping it on the
window) stores each entry whose bytes
re-hash to its `sha`, and skips entries already present. It says *"Restored
1 evidence file"* (with *"· N already present"* when some were skipped).
Entries that fail the re-hash are not stored, with *"Integrity mismatch — 1
evidence file did not match the declared hash and was NOT imported."* A bundle
changes no case data and can be imported before or after its `.pumapack`. If
the boards already have file evidence and none of it matches the bundle, the
app warns *"None of these evidence files match any record on your current
boards — this .pumaevd looks paired with a different .pumapack."*

### 9.4 What an AI must leave untouched for evidence to survive

- **`sha256` on every file evidence record.** Change one character and the
  record no longer finds its bytes, in this browser or from any bundle.
- **`kind`, `size`, `mime` and `filename`** on file records. They are not
  checked against the bytes, so an edit is not caught; it simply makes the
  record describe the file wrongly.
- **`integrity`.** It records what the last Verify found. Import does not
  recompute it.
- **`custody`**, including its order and every `at`.
- **The `.pumaevd` file itself.**

---

## 10. Editing an existing export

The full backup (`pumacase-backup.json`) is the natural file to edit. The
same rules apply to a board pack.

**Preserve exactly as they are:**

- every `id` on boards, cases and records, and every board `slug`;
- `number`, `case_seq`, `tid` and `task_seq`;
- `created_at` on everything, and `at` on timeline and custody events;
- everything in §9.4;
- system timeline events;
- unknown keys you do not understand. They are kept on boards, cases and
  records.

**Safe to change:** titles, descriptions, names, tags, severity, status, TLP,
category, task status and assignee, observables, analyst timeline events,
case notes, and whole new records that follow §6.

**When you change something, update what the app would have updated:**

- set the record's `updated_at`, and the case's and board's `updated_at`;
- a task moved to `"done"` gets `completed_at`; moved away from `"done"`,
  `completed_at: null`;
- a case moved to `"closed"` gets `closed_at`; reopened, `closed_at: null`;
- an analyst timeline event you reword gets `updated_at` set to now, which
  shows "edited";
- a new case gets the next case number and `case_seq` moves up with it;
- a new task gets the next `tid` and `task_seq` moves up with it.

Recording a status change or a task completion as a system timeline event
(§6.9) keeps the timeline consistent with what the app would have written.
It is optional.

**The app recomputes or overwrites on import:** severity, status and TLP
outside their enums; a missing `detected_at`, `accent_color`, `integrity` or
record array; `task_seq` below the highest `tid`; missing `tid`s;
`case_seq` if it is not a number; and categories and SLA, which are rebuilt
field by field (§4.1, §4.2). Nothing else is changed. No timestamp is touched
and no id is regenerated, except a playbook item or category that has none.

**What happens to the user's data:** import replaces every board. Anything in
the browser that is not in the file is gone from the case data after import.
Evidence bytes already stored in the browser stay stored and are found again
by any record with the same `sha256`.

---

## 11. Things that go wrong

| Mistake | What happens |
|---|---|
| No `puma` envelope (a bare `{ "workspaces": [...] }`, or `data` alone) | Refused: *"Not a .pumapack file — missing the puma envelope."* |
| `puma.app` is anything but `"pumacase"` | Refused: *"That pack is from … Open it there instead."* |
| `data.workspaces` missing or `[]`, or a PumaLogger timeline pack | Refused: *"Import failed — no case boards in this pack."* |
| `null` inside `tasks` or `evidence`, or a `null` board | The import stops part-way with no message, and nothing changes. |
| `null` inside `observables` or `timeline` | Imports, then that case's views fail to render. |
| `null` inside `custody` | Imports, then that item's chain of custody fails to open. |
| Missing or duplicate board `slug` | Imported as written. A board with no slug, or the second of two with the same slug, cannot be selected properly. |
| `severity: "High"`, `status: "open"`, `tlp: "TLP:AMBER"` | Silently corrected to `"medium"`, `"new"` and `"amber"`. |
| Task `status: "in_progress"` or `"Done"` | Kept. Shown as the raw id with no color, and does not count as done. |
| Task `group` not in §6.6, e.g. `"Containment"` | The task is **hidden** from the Tasks tab but still counted in progress. |
| Case `category` not on the board | Kept. Shown as the raw id. |
| Observable `type` not in the list, e.g. `"fqdn"` | Kept. Shown as the raw id. |
| An extra key on a category, playbook item or SLA entry | Dropped. |
| `categories: []` | Replaced by the 13 built-in categories. |
| `case_seq` lower than the case numbers used | Imported; the next new case repeats a number. |
| A task without `tid` | Given the next number after `task_seq`. |
| `sha256` edited on a file evidence record | Imported. Download and Verify then report that the bytes are not in this browser. |
| `attachments` bytes that do not hash to their key | Those bytes are not stored. The boards still import. The mismatch warning is overwritten by the success message. |
| `integrity: "verified"` on a record whose bytes are not supplied | Kept as written. It only changes when someone presses Verify. |
| Task `notes` as a string | Moved into the task's `summary` (if that is empty) the first time the Tasks tab is shown. Write an array. |
| `due_at` as a bare date, `"2026-09-23"` | Read as midnight UTC, which is the previous day for readers west of UTC. |
| A timestamp the browser cannot read, e.g. `"21/09/2026"` | Shown as `—`, and SLA figures become nonsense. |
| A timestamp with no `Z` or offset, e.g. `"2026-09-21T08:40:00"` | Read in the viewer's local time, so it moves with the reader. |

---

## 12. Checklist before handing a pack over

A pack that passes all of these imports with no warnings and needs no
renumbering.

**Envelope**
- [ ] `puma.app` is `"pumacase"` and `data.workspaces` is a non-empty array.
- [ ] `data.activeSlug` names one of the boards.
- [ ] `attachments`, if present, is at the top level, beside `data`.

**Boards**
- [ ] Every board has a unique `id` and a unique lower-case `slug`.
- [ ] `categories` lists every category any case on the board uses.
- [ ] `sla` has exactly `critical`, `high`, `medium` and `low`, each with
      positive whole-hour `ack` and `resolve`.
- [ ] `case_seq` equals the highest case number on the board.

**Cases and records**
- [ ] Every field from §5 and §6 is present. No `null` except `closed_at`,
      `due_at` and `completed_at`, and never inside an array.
- [ ] Ids are unique within each array.
- [ ] `task_seq` equals the highest `tid`, and `tid`s are unique per case.
- [ ] Every enum value is an exact id from §6.
- [ ] Closed cases have `closed_at`; done tasks have `completed_at`.
- [ ] Custody events are in chronological order.

**Evidence**
- [ ] Every `file` record has a 64-digit lower-case hex `sha256`, and
      `integrity` is `"unverified"` unless it came from the app.
- [ ] Every `link` and `note` record has `sha256: ""` and `integrity: "n/a"`.
- [ ] Every `attachments` key equals the SHA-256 of its decoded bytes and
      matches a record.

**Values**
- [ ] Every timestamp is a full ISO datetime ending in `Z`.

---

## 13. A complete example

A full backup with one board and two cases. The open phishing case has
three tasks (one done, one with a note), three observables, one evidence item
of each kind (the file's bytes carried in `attachments`), a custody transfer,
system and analyst timeline events including an edited one, and a case note.
The second case is closed. It imports with no warnings, showing *"Imported
1 board(s) · 2 case(s)"*, and every field is stored exactly as written.

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumacase",
    "appVersion": "generated",
    "format": 1,
    "exportedAt": "2026-09-22T12:00:00.000Z",
    "kind": "backup",
    "title": "PumaCase backup"
  },
  "data": {
    "workspaces": [
      {
        "id": "ws-soc",
        "slug": "soc-cases",
        "name": "SOC cases",
        "accent_color": "#5b8af0",
        "created_at": "2026-09-01T09:00:00.000Z",
        "updated_at": "2026-09-22T11:30:00.000Z",
        "case_seq": 2,
        "categories": [
          { "id": "phishing", "label": "Phishing / BEC", "playbook": [
            { "id": "pt-ph1", "title": "Confirm the report and pull the email", "group": "triage" },
            { "id": "pt-ph2", "title": "Identify all recipients and who interacted", "group": "analysis" },
            { "id": "pt-ph3", "title": "Block sender, URLs, and attachments", "group": "containment" }
          ] },
          { "id": "other", "label": "Other", "playbook": [] }
        ],
        "sla": {
          "critical": { "ack": 1, "resolve": 24 },
          "high": { "ack": 4, "resolve": 72 },
          "medium": { "ack": 24, "resolve": 168 },
          "low": { "ack": 72, "resolve": 336 }
        },
        "cases": [
          {
            "id": "case-inv4471",
            "number": "IR-2026-001",
            "title": "Invoice-themed phishing to finance team",
            "description": "A finance user reported an overdue-invoice email from a look-alike supplier domain. The link leads to a credential-harvesting page.",
            "severity": "high",
            "status": "analysis",
            "tlp": "amber",
            "category": "phishing",
            "assignee": "Priya Shah",
            "reporter": "Jordan Lee",
            "tags": ["phishing", "finance"],
            "detected_at": "2026-09-21T08:40:00.000Z",
            "created_at": "2026-09-21T09:05:00.000Z",
            "updated_at": "2026-09-22T11:30:00.000Z",
            "closed_at": null,
            "task_seq": 3,
            "tasks": [
              { "id": "task-a1", "tid": 1, "title": "Confirm the report and pull the email", "status": "done",
                "group": "triage", "assignee": "Priya Shah", "due_at": null, "summary": "Original message pulled from the mailbox.",
                "notes": [], "created_at": "2026-09-21T09:05:00.000Z", "updated_at": "2026-09-21T10:02:00.000Z",
                "completed_at": "2026-09-21T10:02:00.000Z" },
              { "id": "task-a2", "tid": 2, "title": "Identify all recipients and who interacted", "status": "in-progress",
                "group": "analysis", "assignee": "Priya Shah", "due_at": "2026-09-23T12:00:00.000Z", "summary": "",
                "notes": [ { "id": "tn-a2-1", "at": "2026-09-22T09:15:00.000Z", "text": "Message trace shows 14 recipients; 3 clicked." } ],
                "created_at": "2026-09-21T09:05:00.000Z", "updated_at": "2026-09-22T09:15:00.000Z", "completed_at": null },
              { "id": "task-a3", "tid": 3, "title": "Block sender, URLs, and attachments", "status": "todo",
                "group": "containment", "assignee": "", "due_at": null, "summary": "",
                "notes": [], "created_at": "2026-09-21T09:05:00.000Z", "updated_at": "2026-09-21T09:05:00.000Z",
                "completed_at": null }
            ],
            "observables": [
              { "id": "obs-a1", "type": "domain", "value": "invoice-northwind.co", "tlp": "amber", "is_ioc": true,
                "sightings": 1, "notes": "Look-alike of the real supplier domain", "tags": [], "created_at": "2026-09-21T09:20:00.000Z" },
              { "id": "obs-a2", "type": "ip", "value": "203.0.113.45", "tlp": "amber", "is_ioc": true,
                "sightings": 2, "notes": "Sending mail server", "tags": ["smtp"], "created_at": "2026-09-21T09:20:00.000Z" },
              { "id": "obs-a3", "type": "email", "value": "jordan.lee@example.com", "tlp": "amber", "is_ioc": false,
                "sightings": 1, "notes": "Reporting user", "tags": [], "created_at": "2026-09-21T09:20:00.000Z" }
            ],
            "evidence": [
              { "id": "ev-a1", "kind": "file", "name": "phishing-headers.txt", "filename": "phishing-headers.txt",
                "mime": "text/plain", "size": 269,
                "sha256": "184f9753e68c7598c3c460c940835c1eb48244e02db170daa521734137b6be9f",
                "url": "", "body": "", "source": "Reporter's mailbox",
                "acquired_by": "Priya Shah", "acquired_at": "2026-09-21T09:20:00.000Z",
                "description": "Header block of the reported message", "integrity": "unverified",
                "custody": [
                  { "id": "coc-a1", "action": "acquired", "actor": "Priya Shah", "from": "", "to": "",
                    "note": "File acquired and hashed (SHA-256)", "at": "2026-09-21T09:20:00.000Z" },
                  { "id": "coc-a2", "action": "transferred", "actor": "Priya Shah", "from": "", "to": "Email security team",
                    "note": "Shared for sender blocking", "at": "2026-09-22T08:30:00.000Z" }
                ],
                "created_at": "2026-09-21T09:20:00.000Z" },
              { "id": "ev-a2", "kind": "link", "name": "Sandbox report for the landing page", "filename": "",
                "mime": "", "size": 0, "sha256": "",
                "url": "https://sandbox.example.org/report/8841", "body": "", "source": "",
                "acquired_by": "Priya Shah", "acquired_at": "2026-09-21T11:00:00.000Z",
                "description": "", "integrity": "n/a",
                "custody": [
                  { "id": "coc-a3", "action": "acquired", "actor": "Priya Shah", "from": "", "to": "",
                    "note": "Link recorded", "at": "2026-09-21T11:00:00.000Z" }
                ],
                "created_at": "2026-09-21T11:00:00.000Z" },
              { "id": "ev-a3", "kind": "note", "name": "User interview", "filename": "",
                "mime": "", "size": 0, "sha256": "",
                "url": "", "body": "Jordan opened the link but did not enter credentials.\nClosed the tab when the page asked for a password.", "source": "",
                "acquired_by": "Priya Shah", "acquired_at": "2026-09-21T10:30:00.000Z",
                "description": "", "integrity": "n/a",
                "custody": [
                  { "id": "coc-a4", "action": "acquired", "actor": "Priya Shah", "from": "", "to": "",
                    "note": "Note recorded", "at": "2026-09-21T10:30:00.000Z" }
                ],
                "created_at": "2026-09-21T10:30:00.000Z" }
            ],
            "timeline": [
              { "id": "tl-a1", "kind": "system", "title": "Case opened", "detail": "", "actor": "Priya Shah",
                "at": "2026-09-21T09:05:00.000Z", "system": true, "created_at": "2026-09-21T09:05:00.000Z", "updated_at": "" },
              { "id": "tl-a2", "kind": "detection", "title": "User reported a suspicious invoice email",
                "detail": "Reported through the phishing button.", "actor": "Jordan Lee",
                "at": "2026-09-21T08:40:00.000Z", "system": false, "created_at": "2026-09-21T09:06:00.000Z", "updated_at": "" },
              { "id": "tl-a3", "kind": "system", "title": "Evidence added: phishing-headers.txt",
                "detail": "SHA-256 `184f9753e68c7598c3c460c940835c1eb48244e02db170daa521734137b6be9f`", "actor": "Priya Shah",
                "at": "2026-09-21T09:20:00.000Z", "system": true, "created_at": "2026-09-21T09:20:00.000Z", "updated_at": "" },
              { "id": "tl-a4", "kind": "system", "title": "Status → Analysis", "detail": "Was New.", "actor": "",
                "at": "2026-09-21T09:30:00.000Z", "system": true, "created_at": "2026-09-21T09:30:00.000Z", "updated_at": "" },
              { "id": "tl-a5", "kind": "system", "title": "IR-2026-001-T01 Completed: Confirm the report and pull the email",
                "detail": "", "actor": "Priya Shah",
                "at": "2026-09-21T10:02:00.000Z", "system": true, "created_at": "2026-09-21T10:02:00.000Z", "updated_at": "" },
              { "id": "tl-a6", "kind": "finding", "title": "Three recipients clicked the link",
                "detail": "Proxy logs show visits from three finance workstations between 08:15 and 08:35 UTC.", "actor": "Priya Shah",
                "at": "2026-09-22T09:10:00.000Z", "system": false, "created_at": "2026-09-22T09:12:00.000Z",
                "updated_at": "2026-09-22T09:40:00.000Z" }
            ],
            "notes": [
              { "id": "note-a1", "body": "Check whether any of the three clickers reused the password elsewhere.",
                "author": "Priya Shah", "created_at": "2026-09-21T11:10:00.000Z", "updated_at": "2026-09-22T09:45:00.000Z" }
            ]
          },
          {
            "id": "case-badge",
            "number": "IR-2026-002",
            "title": "Contractor badge reported lost",
            "description": "Badge disabled within the hour; no use after the loss.",
            "severity": "low",
            "status": "closed",
            "tlp": "green",
            "category": "other",
            "assignee": "Sam Ortiz",
            "reporter": "Facilities desk",
            "tags": [],
            "detected_at": "2026-09-15T14:00:00.000Z",
            "created_at": "2026-09-15T14:10:00.000Z",
            "updated_at": "2026-09-16T10:00:00.000Z",
            "closed_at": "2026-09-16T10:00:00.000Z",
            "task_seq": 0,
            "tasks": [],
            "observables": [],
            "evidence": [],
            "timeline": [
              { "id": "tl-b1", "kind": "system", "title": "Case opened", "detail": "", "actor": "Sam Ortiz",
                "at": "2026-09-15T14:10:00.000Z", "system": true, "created_at": "2026-09-15T14:10:00.000Z", "updated_at": "" },
              { "id": "tl-b2", "kind": "system", "title": "Status → Closed", "detail": "Was New.", "actor": "",
                "at": "2026-09-16T10:00:00.000Z", "system": true, "created_at": "2026-09-16T10:00:00.000Z", "updated_at": "" }
            ],
            "notes": []
          }
        ]
      }
    ],
    "activeSlug": "soc-cases",
    "accent": null
  },
  "attachments": {
    "184f9753e68c7598c3c460c940835c1eb48244e02db170daa521734137b6be9f": {
      "mime": "text/plain",
      "b64": "UmV0dXJuLVBhdGg6IDxiaWxsaW5nQGludm9pY2Utbm9ydGh3aW5kLmNvPgpSZWNlaXZlZDogZnJvbSBtYWlsLmludm9pY2Utbm9ydGh3aW5kLmNvICgyMDMuMC4xMTMuNDUpCkZyb206ICJBY2NvdW50cyBQYXlhYmxlIiA8YmlsbGluZ0BpbnZvaWNlLW5vcnRod2luZC5jbz4KVG86IGpvcmRhbi5sZWVAZXhhbXBsZS5jb20KU3ViamVjdDogT3ZlcmR1ZSBpbnZvaWNlIDQ0NzEgLSBhY3Rpb24gcmVxdWlyZWQKRGF0ZTogTW9uLCAyMSBTZXAgMjAyNiAwODoxMjo0MCArMDAwMAo="
    }
  }
}
```

What the app shows from this, as a check on your own reasoning. These were
observed by importing this exact file into a fresh copy of the app:

- One board, **SOC cases**, with two cases. Every field above is stored
  unchanged, and the app's own full backup of the result contains the same
  `data` apart from `exportedAt` and `appVersion`. `attachments` is not
  written back out; the bytes are now stored in the browser.
- The 269-byte header file is stored under its hash. Its record still reads
  **Unverified**. Pressing **Verify** re-hashes it, says *"Integrity verified
  — stored bytes re-hashed and match."*, sets `integrity` to `"verified"` and
  appends a `verified` custody event.
- `IR-2026-001` is High, so its resolve deadline is 72 hours after
  2026-09-21 08:40 UTC. Opened after 2026-09-24 08:40 UTC, it reads
  *"Overdue by …"*.
- `IR-2026-002` reads *"Met · 20h 0m"*: closed 20 hours after detection,
  inside Low's 336 hours.
- The Tasks tab shows 1 of 3 done. The next task added to `IR-2026-001` will
  be `T04`, and the next case on the board will be `IR-<current year>-003`.
