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

This document describes PumaLogger's `.pumapack` files in enough detail to
**edit an export or generate a new pack from scratch** that imports cleanly.
The app opens the result with no warnings, no re-stamped ids and no dropped
entries. 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. PumaLogger imports it in either of two
ways:

- from the topbar **Import** button (or ⌘O);
- by dropping the file anywhere on the window.

Import always **adds** workspaces as new tabs. It never replaces or merges
into a workspace the user already has. (Merging is a separate, deliberate
step in the app: right-click a tab, then **Merge into…**.)

"Schema 1" is the pack format number, written as `puma.format`. It is the
number the app's storage dialog shows as its schema version.

---

## 1. The short version

If you only read one section, read this one.

1. Use the **environment** shape from §2: an envelope whose `data.workspaces`
   is an array of workspaces. This is what the topbar Export (⌘S) writes.
2. Save the file with a `.pumapack` or `.json` name. A `.txt`, `.log`,
   `.syslog`, `.csv` or `.tsv` name sends it to a different importer.
3. Write **every field** of every workspace and entry, using the shapes in §4
   and §5. Use `""`, `[]`, `false` or `null` exactly where §4 and §5 say.
4. Every entry's `chip` must be the `id` of a chip in that workspace's
   `chips`. Anything else is silently changed to the default chip.
5. Write every time as `YYYY-MM-DDTHH:MM:SSZ` (UTC, whole seconds).
6. Every entry's `id` is **derived from its `ts`**: `YYMMDD.hhmmss` in UTC,
   plus `-1`, `-2` for later entries in the same second. An id that does not
   match its `ts` is replaced. See §6.
7. Tags and stakeholders are lower-case words **without** the `#` or `@`.
8. Leave the workspace's filters empty (`tags: []`, `stakeholders: []`,
   `search: ""`) and list every chip in `chipFilter`, or the workspace opens
   with entries hidden.
9. Set `tz` to `"local"`, `"UTC"` or a real IANA zone name such as
   `"Europe/London"`. An unknown zone name breaks the app's display.
10. Check the result against the checklist in §10.

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

---

## 2. The two pack shapes

PumaLogger writes two shapes and reads both.

| Shape | Written by | `puma.kind` | Holds |
|---|---|---|---|
| **Environment** | Topbar **Export**, or ⌘S | `"environment"` | Every workspace, which tab was open, and the app's theme and accent |
| **Workspace** | Right-click a tab, then **Export as .pumapack** | `"workspace"` | One workspace's chips, default chip, time zone and entries |

Lead with the environment shape. It is the one a user most naturally
exports and hands over, it can hold one workspace or many, and it carries
every workspace setting. The workspace shape is covered in §2.3.

### 2.1 The environment envelope

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumalogger",
    "appVersion": "generated",
    "format": 1,
    "kind": "environment",
    "exportedAt": "2026-09-21T16:00:00Z",
    "title": "PumaLogger environment · 2 workspaces · 8 entries"
  },
  "data": {
    "workspaces": [ { "...one workspace object, see §4..." } ],
    "activeWorkspaceId": "ws_payroll",
    "prefs": { "theme": null, "accent": null }
  }
}
```

| Key | Value | Notes |
|---|---|---|
| `$schema` | `"https://pumaworx.dev/pumapack/v1"` | Not checked on import. Write it. |
| `puma.app` | `"pumalogger"` | Not checked for this shape. Write it; other tools check it. |
| `puma.appVersion` | any string | Free text. The app writes its build id, or `"dev"`. |
| `puma.format` | `1` | The pack format. Not checked on import. |
| `puma.kind` | `"environment"` | Not checked on import; the shape is recognized by `data.workspaces`. |
| `puma.exportedAt` | ISO 8601 datetime | Informational. |
| `puma.title` | string | Informational. |
| `data.workspaces` | array of workspace objects | **Must be an array.** One or more. |
| `data.activeWorkspaceId` | a workspace `id` from this file, or `null` | The tab that is open after import. `null` or no match opens the first imported workspace. |
| `data.prefs.theme` | `"light"`, `"dark"` or `null` | **Applied to the whole app** on import. Any other value is ignored. |
| `data.prefs.accent` | `"#rgb"`, `"#rrggbb"` or `null` | **Applied to the whole app** on import. `null` leaves the user's accent alone. |

A generated pack should normally set both `data.prefs` values to `null`, so
the import does not change the user's theme or accent.

### 2.2 What the importer requires

The importer checks, in this order:

| Check | If it fails, the app says |
|---|---|
| The file can be read | *"Could not read file"* |
| The file name does not end in `.csv`, `.tsv`, `.log`, `.syslog` or `.txt` | Nothing is refused: the file goes to the CSV or plain-log importer instead, and every line of the JSON becomes an entry. |
| The text is valid JSON | *"File is not valid JSON"* |
| The top-level object has a `data` key | *"No .data envelope found"* |
| `data.workspaces` is an array with at least one element | *"Environment pack had no workspaces"* |

A bare workspace object, with no envelope around it, is rejected with *"No
.data envelope found"*.

When `data.workspaces` is an array, the file is read as an environment pack
and nothing else in `data` is looked at. Otherwise the importer tries the
workspace shape (§2.3).

On success the app says:

> Imported 2 workspaces · 8 entries

The count is every entry imported, archived ones included. Nothing else is
asked; there is no confirm dialog and no choice to make.

On import:

- each workspace becomes a **new tab**, added after the tabs the user
  already has, in array order;
- each workspace gets a **new `id`**; the `id` in the file is used only to
  match `activeWorkspaceId`;
- a workspace with no entries is kept, as an empty tab.

### 2.3 The workspace shape

This is what a tab's **Export as .pumapack** writes. It holds exactly one
workspace, split across two keys:

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumalogger", "appVersion": "generated", "format": 1,
    "kind": "workspace", "exportedAt": "2026-09-21T16:00:00Z",
    "title": "Payroll phishing, Sept 2026 · 6 entries"
  },
  "data": {
    "workspace": {
      "name": "Payroll phishing, Sept 2026",
      "chips": [ { "id": "information", "label": "Information", "short": "info", "color": "#9aa0b0" } ],
      "defaultChip": "information",
      "prefs": { "tz": "UTC" }
    },
    "entries": [ { "...entry objects, see §5..." } ]
  }
}
```

- `data.entries` is the entry array, at the top of `data`, **not** inside
  `data.workspace`.
- `data.workspace` carries `name`, `chips`, `defaultChip` and `prefs` with
  the same meaning as in §4. The app writes only `tz` in these `prefs`, but
  the other §4.2 keys are read too.
- `createdAt` is **not** read from this shape; the tab is stamped with the
  import time.
- If `data.workspace.name` is missing, the tab is named from `puma.title`,
  then `"Imported"`.

What the importer requires for this shape, after the checks in §2.2:

| Check | If it fails, the app says |
|---|---|
| `data.entries` is an array (when `puma.app` is missing or `"pumalogger"`) | *"Pack has no data.entries[] — nothing to import."* |
| `data.entries` is an array (when `puma.app` names another app) | *"That's a PumaX pumapack — PumaLogger doesn't know how to read it. Open it in PumaX instead."*, with the other app's name |
| At least one entry was imported | *"Nothing imported — pack had no valid entries"* |

On success the app says, for example:

> Imported 6 into "Payroll phishing, Sept 2026"

### 2.4 Packs from other apps

PumaLogger also opens PumaTracker and PumaNoter packs and turns each task or
note into an entry, in a new workspace. That is a one-way conversion from
those apps' own formats, which are not described here. Other apps' packs are
refused with the message in §2.3.

---

## 3. What a pack holds

```
envelope
└── data.workspaces[]        one per tab (§4)
    ├── chips[]              the workspace's categories (§4.1)
    ├── prefs                filters, time zone, default chip for new entries (§4.2)
    └── entries[]            the log itself (§5)
```

There are no other record types. There is no people list: stakeholders are
plain words on each entry (§7).

---

## 4. The workspace object

```json
{
  "id": "ws_payroll",
  "name": "Payroll phishing, Sept 2026",
  "chips": [ ],
  "defaultChip": "information",
  "prefs": { },
  "createdAt": "2026-09-21T12:40:00Z",
  "entries": [ ]
}
```

| Field | Type | Notes |
|---|---|---|
| `id` | string | Any string, unique within the file. **Replaced with a new id on import.** Used only to match `data.activeWorkspaceId`. |
| `name` | string | The tab name. Missing or `""` becomes `"Imported"`. |
| `chips` | array of chip objects | The workspace's categories, in display order. See §4.1. |
| `defaultChip` | a chip `id` | The chip an entry falls back to when its own chip is unknown. If it is not in `chips`, the first chip is used. |
| `prefs` | object | See §4.2. |
| `createdAt` | ISO 8601 datetime | When the workspace was made. Kept if it parses; otherwise the import time is used. |
| `entries` | array of entry objects | See §5. Missing, `null` or not an array gives an empty workspace. |

**Any other workspace key is dropped.** That includes a tab color: a pack
does not carry the color picked in **About workspace**, and every imported
tab shows its default chip's color.

Older builds also wrote a `columnId` key. It is ignored on import; leave it
out.

### 4.1 `chips[]`

```json
{ "id": "observation", "label": "Observation", "short": "obs", "color": "#3fb8b0" }
```

| Field | Type | Notes |
|---|---|---|
| `id` | string | What entries point at. Lower-case letters, digits and `-`/`_`. Unique within the workspace. |
| `label` | string | The chip's name, shown on each row of the feed, in the chip picker and in the filters. The chip editor allows up to 32 characters. |
| `short` | string | A short name, used in the text exports (Markdown, RTF and copied timelines) and recognized as the chip's name when a log is pasted in. The chip editor allows up to 16 characters. |
| `color` | `"#rrggbb"` | The chip's color. |

- **All four fields must be strings.** A chip missing any of them is dropped
  without a message, and entries that used it move to `defaultChip`.
- Array order is display order.
- If `chips` is missing or empty, the workspace gets one of the built-in
  palettes below, picked by which palette's ids most of its entries use,
  and the Level palette when none match.

The three built-in palettes, id first:

| Palette | Chip ids, in order | Default |
|---|---|---|
| Level (syslog) | `trace` `debug` `info` `warn` `error` `fatal` | `info` |
| Research | `question` `hypothesis` `procedure` `observation` `measurement` `anomaly` `result` `reference` | `observation` |
| Investigation | `information` `observation` `evidence` `hypothesis` `assessment` `decision` `action` `communication` | `information` |

A generated pack should always write its `chips` in full, even when it uses
a built-in palette, so the result does not depend on the guess.

### 4.2 `prefs`

```json
{
  "chipFilter": ["information", "observation", "evidence", "decision", "action", "communication"],
  "tags": [],
  "stakeholders": [],
  "search": "",
  "composerChip": "information",
  "tz": "UTC",
  "recentTz": []
}
```

| Field | Type | Notes |
|---|---|---|
| `chipFilter` | array of chip ids | The chips currently **shown**. Entries whose chip is left out are hidden until the user turns the chip back on. Ids not in `chips` are removed. An empty or missing list shows every chip. **Write every chip id.** |
| `tags` | array of strings | An **active filter**: only entries carrying all of these tags are shown. Write `[]`. |
| `stakeholders` | array of strings | An **active filter**, the same way. Write `[]`. |
| `search` | string | An **active search**. Write `""`. |
| `composerChip` | a chip id | The chip preselected for the next entry the user types. If it is not in `chips`, `defaultChip` is used. |
| `tz` | `"local"`, `"UTC"` or an IANA zone name | The time zone this workspace **displays** times in. It does not change what is stored. See §6. |
| `recentTz` | array | Written by the app, **ignored on import**; the list starts empty. Write `[]`. |

Any other key in `prefs` is ignored.

---

## 5. The entry object

```json
{
  "id": "260921.130410",
  "ts": "2026-09-21T13:04:10Z",
  "created_at": "2026-09-21T13:04:10Z",
  "chip": "observation",
  "title": "Three staff report an email asking them to confirm payroll bank details",
  "description": "Reported through the phish button between 12:55 and 13:02.",
  "tags": ["phishing", "payroll"],
  "stakeholders": ["helpdesk"],
  "edited_at": null,
  "archived": false
}
```

| Field | Type | Notes |
|---|---|---|
| `id` | string | `YYMMDD.hhmmss` from `ts` in UTC, with an optional `-N` suffix. **Derived, not chosen.** See §6. |
| `ts` | ISO 8601 UTC datetime | When the event happened. The feed is ordered by it and shows it. **Must parse.** |
| `created_at` | ISO 8601 UTC datetime | When the entry was written down. Never changes once set. If missing, it is set to `ts`. |
| `chip` | a chip id | The entry's category. **Must be in the workspace's `chips`**, or it becomes `defaultChip` without a message. |
| `title` | string | The headline: one line of plain text. See §7 for why it should not contain `#` or `@` words. |
| `description` | string | Optional detail. **Markdown**, with `\n` for line breaks. `""` for none. |
| `tags` | array of strings | Topics. Lower-case, no `#`. See §7. |
| `stakeholders` | array of strings | People, teams or systems involved. Lower-case, no `@`. See §7. |
| `edited_at` | ISO 8601 UTC datetime or `null` | When the entry was last changed by hand. `null` for never. Any non-string becomes `null`. |
| `archived` | boolean | `true` hides the entry from the feed. It is still in the workspace and in every backup. |

**Any other entry key is dropped.** Each entry is rebuilt from exactly the
ten fields above.

Array order does not matter to the display; the feed is always sorted by
`ts`, newest first. It is kept as written, though, and the app's own export
writes entries in the order they were added.

---

## 6. Times, ids and ordering

### Time format

- Write every time as `YYYY-MM-DDTHH:MM:SSZ`, for example
  `"2026-09-21T13:04:10Z"`. That is what the app writes: UTC, whole seconds,
  no milliseconds.
- A time with an offset (`"2026-09-21T15:04:10+02:00"`) is read correctly,
  but write `Z` so the id rule below is easy to apply by hand.
- `ts` is **not validated** beyond being a string. A value that does not
  parse, such as `"yesterday afternoon"`, is kept, sorts to the bottom of the
  feed and displays as `NaN:NaN:NaN`.
- The workspace's `prefs.tz` changes only how times are **shown**. Stored
  times are always absolute instants.
- `prefs.tz` is **not validated.** A name the browser does not recognize,
  such as `"Europe/Londn"`, is saved, and then every attempt to display that
  workspace fails: the import shows no message, the feed stays empty, and it
  stays empty after a reload while that workspace is the open tab.

### Entry ids

The id is the entry's `ts` in UTC, written as `YYMMDD.hhmmss`:

| `ts` | `id` |
|---|---|
| `2026-09-21T13:04:10Z` | `260921.130410` |
| `2026-09-21T15:04:10+02:00` | `260921.130410` |
| `2026-12-31T23:59:59Z` | `261231.235959` |

- **Same second.** When two entries in one workspace share a `ts` second,
  the first keeps the plain id and the next ones take `-1`, `-2` and so on:
  `260921.132200`, `260921.132200-1`.
- **What the importer does:**
  - an id whose part before any `-N` does not equal the id derived from
    `ts` is **replaced** with the derived one;
  - an id that repeats one already seen in the same workspace gets the next
    free `-N` suffix.
- So ids are never free-form. Write the derived id, and the import changes
  nothing.
- Ids only need to be unique within their own workspace. They may repeat
  across workspaces, and across the user's existing tabs.

### `ts`, `created_at` and `edited_at`

These three tell the entry's history, and the app shows them in the entry's
details:

| Situation | `ts` | `created_at` | `edited_at` |
|---|---|---|---|
| Logged as it happened | the moment | the same moment | `null` |
| Logged later, with the real time set by hand | when it happened | when it was written down | when it was written down |
| Changed after it was logged | as now | unchanged | when it was changed |

- An entry with a non-null `edited_at` shows an **edited** mark in the feed.
- In the entry's details, an `edited_at` more than five minutes after
  `created_at` is highlighted.
- For a timeline reconstructed from notes, setting `created_at` equal to
  `ts` and `edited_at` to `null` is the plain choice, and imports cleanly.

---

## 7. Tags and stakeholders

In the app, a user tags an entry by typing `#word` or `@word` in the
headline. The app removes those words from the headline and stores them in
two arrays:

- `#word` goes into `tags` (topics);
- `@word` goes into `stakeholders` (people, teams, systems).

The stored arrays hold the bare word, with no `#` or `@`. So, in a pack:

- **Write the bare word:** `"tags": ["phishing"]`, not `["#phishing"]`. A
  leading `#` or `@` is kept as part of the word and shows up in the tag.
- **Use lower case.** The importer lower-cases every tag and stakeholder.
  `"Payroll"` is stored as `"payroll"`.
- **Use one word:** letters, digits, `-` and `_`, starting with a letter or
  digit, at most 32 characters. That is what the app itself produces when a
  user types a tag. Other strings are kept, but a tag with a space in it
  cannot be typed into the search box.
- **Keep the title clean.** Put tags in the arrays, not in `title`. A
  `#word` left in a title stays there as literal text.
- Non-string elements are removed. Duplicates are not removed.
- If `tags` or `stakeholders` is **missing** (not `[]`), the importer
  rebuilds it from `#word` and `@word` in the title and description, and
  those words stay in the text. Write both arrays, even when empty.

Stakeholder names are free text. Spell each one identically on every entry,
because the filter sidebar lists every distinct spelling as a separate
stakeholder.

---

## 8. Editing an existing export

When you are given an export and asked to change it:

**Keep as they are**

- every entry's `id`, `created_at` and `archived`;
- each workspace's `chips`, `defaultChip`, `prefs` and `createdAt`;
- the envelope, including `puma.kind`.

**When you change an entry**

- If you change its `ts`, change its `id` to match (§6). If you forget, the
  import re-derives it; if the new id collides, a `-N` suffix is added.
- Set `edited_at` to the current time, as the app does when a user edits an
  entry. Never change `created_at`.
- To remove an entry from view without deleting it, set `"archived": true`.

**When you add an entry**

- Derive its `id` from its `ts` and check it against every other entry in
  the same workspace. Add `-1`, `-2` if the second is taken.
- Use a chip id from that workspace's `chips`.

**What the app recomputes or overwrites**

- Workspace `id`s: always replaced.
- Entry `id`s: replaced if they do not match `ts`.
- `prefs.recentTz`: reset to `[]`.
- `puma.*`, `$schema`: not read.

**What to tell the user**

- Import adds new tabs. Importing an edited copy of a backup the user has
  already imported gives them **both** versions side by side. They can close
  the old tabs, or right-click the new tab and use **Merge into…**, which
  skips entries that are identical in both.
- A full environment export also carries every other tab the user had. If
  the task is about one workspace, hand back only that workspace in
  `data.workspaces`.

---

## 9. Things that go wrong

| Mistake | What happens |
|---|---|
| A workspace object with no envelope | Rejected: *"No .data envelope found"*. |
| The file saved as `.txt` or `.log` | Read as a plain log: every line of the JSON becomes an entry. |
| `data.workspaces: []` | *"Environment pack had no workspaces"*. |
| An entry `chip` not in the workspace's `chips` | Changed to `defaultChip`, with no message. |
| A chip missing `label`, `short` or `color` | The chip is dropped with no message, and its entries move to `defaultChip`. |
| An entry `id` that does not match its `ts` | Replaced with the id derived from `ts`. |
| `ts` that does not parse | Kept. The row shows `NaN:NaN:NaN` and sorts last. |
| `prefs.tz` not a real zone name | The import shows no message, the feed is empty, and it stays empty after a reload while that workspace is open. |
| `prefs.tags`, `prefs.stakeholders` or `prefs.search` not empty | The workspace opens filtered, hiding entries. |
| `chipFilter` missing some chips | Entries with those chips are hidden. |
| `"#phishing"` or `"@dana"` in the arrays | The `#` or `@` is kept as part of the word. |
| `tags` or `stakeholders` left out | Rebuilt from `#word` and `@word` in the title and description. |
| `null` or a string as an element of `entries` | Not skipped: it becomes a **blank entry dated at the moment of import**, and is counted in the success message. |
| `entries: null` on a workspace | An empty tab. |
| `data.prefs.theme` or `accent` set | The user's theme or accent changes for the whole app. |
| An unknown key on a workspace or entry | Dropped silently. |
| Importing the same pack twice | Two copies of every workspace. |

---

## 10. Checklist before handing a pack over

A pack that passes all of these opens with no warnings and needs no
re-stamping.

**Structure**
- [ ] The file name ends in `.pumapack` or `.json`.
- [ ] The envelope matches §2.1, and `data.workspaces` is a non-empty array
      (or the file uses the workspace shape in §2.3 exactly).
- [ ] Every workspace and entry has every field from §4 and §5.
- [ ] `data.activeWorkspaceId` is one of the workspace `id`s, or `null`.
- [ ] `data.prefs.theme` and `data.prefs.accent` are `null` unless the user
      asked for a change.

**Chips**
- [ ] Every chip has string `id`, `label`, `short` and `color`.
- [ ] Every entry's `chip`, each workspace's `defaultChip` and
      `prefs.composerChip` are ids in that workspace's `chips`.
- [ ] `prefs.chipFilter` lists every chip id.

**Filters and zone**
- [ ] `prefs.tags` and `prefs.stakeholders` are `[]`, and `prefs.search` is
      `""`.
- [ ] `prefs.tz` is `"local"`, `"UTC"` or a valid IANA zone name.

**Entries**
- [ ] Every `ts`, `created_at` and non-null `edited_at` is
      `YYYY-MM-DDTHH:MM:SSZ`.
- [ ] Every `id` is derived from its `ts` (§6), with `-N` suffixes for
      entries in the same second, and is unique in its workspace.
- [ ] `tags` and `stakeholders` are arrays of lower-case words with no `#`
      or `@`, and no mention words are left in any `title`.
- [ ] Stakeholder names are spelled identically everywhere.

---

## 11. A complete example

A small environment pack: an incident timeline on a custom six-chip palette,
and a change log on the built-in Level palette. It exercises every field,
including an archived entry, two entries in the same second, and an entry
logged after the fact. It imports with no warnings.

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumalogger",
    "appVersion": "generated",
    "format": 1,
    "kind": "environment",
    "exportedAt": "2026-09-21T16:00:00Z",
    "title": "PumaLogger environment · 2 workspaces · 8 entries"
  },
  "data": {
    "workspaces": [
      {
        "id": "ws_payroll",
        "name": "Payroll phishing, Sept 2026",
        "chips": [
          { "id": "information",   "label": "Information",   "short": "info",     "color": "#9aa0b0" },
          { "id": "observation",   "label": "Observation",   "short": "obs",      "color": "#3fb8b0" },
          { "id": "evidence",      "label": "Evidence",      "short": "evidence", "color": "#5fbf6f" },
          { "id": "decision",      "label": "Decision",      "short": "decision", "color": "#d97373" },
          { "id": "action",        "label": "Action",        "short": "action",   "color": "#d42b2b" },
          { "id": "communication", "label": "Communication", "short": "comms",    "color": "#5b8af0" }
        ],
        "defaultChip": "information",
        "prefs": {
          "chipFilter": ["information", "observation", "evidence", "decision", "action", "communication"],
          "tags": [],
          "stakeholders": [],
          "search": "",
          "composerChip": "information",
          "tz": "UTC",
          "recentTz": []
        },
        "createdAt": "2026-09-21T12:40:00Z",
        "entries": [
          {
            "id": "260921.124500", "ts": "2026-09-21T12:45:00Z", "created_at": "2026-09-21T12:45:00Z",
            "chip": "information", "title": "Placeholder opened before the first report came in",
            "description": "", "tags": [], "stakeholders": [],
            "edited_at": null, "archived": true
          },
          {
            "id": "260921.130410", "ts": "2026-09-21T13:04:10Z", "created_at": "2026-09-21T13:04:10Z",
            "chip": "observation", "title": "Three staff report an email asking them to confirm payroll bank details",
            "description": "Reported through the phish button between 12:55 and 13:02. The display name is the CFO; the reply-to address is an outside domain.",
            "tags": ["phishing", "payroll"], "stakeholders": ["helpdesk"],
            "edited_at": null, "archived": false
          },
          {
            "id": "260921.131500", "ts": "2026-09-21T13:15:00Z", "created_at": "2026-09-21T13:15:00Z",
            "chip": "evidence", "title": "Original message saved with full headers",
            "description": "- Case folder: `IR-2026-114/email/`\n- SHA-256 of the .eml recorded in the case notes",
            "tags": ["phishing"], "stakeholders": [],
            "edited_at": null, "archived": false
          },
          {
            "id": "260921.132200", "ts": "2026-09-21T13:22:00Z", "created_at": "2026-09-21T13:22:00Z",
            "chip": "decision", "title": "Pull the message from every mailbox",
            "description": "", "tags": ["phishing"], "stakeholders": ["dana"],
            "edited_at": null, "archived": false
          },
          {
            "id": "260921.132200-1", "ts": "2026-09-21T13:22:00Z", "created_at": "2026-09-21T13:22:00Z",
            "chip": "action", "title": "Purge job started for 41 mailboxes",
            "description": "", "tags": [], "stakeholders": ["mail-team"],
            "edited_at": null, "archived": false
          },
          {
            "id": "260921.140000", "ts": "2026-09-21T14:00:00Z", "created_at": "2026-09-21T14:12:31Z",
            "chip": "communication", "title": "Staff notice sent: do not reply, report through the phish button",
            "description": "Logged after the fact; the notice went out at 14:00.",
            "tags": ["payroll"], "stakeholders": ["comms"],
            "edited_at": "2026-09-21T14:12:31Z", "archived": false
          }
        ]
      },
      {
        "id": "ws_gateway",
        "name": "Mail gateway changes",
        "chips": [
          { "id": "trace", "label": "trace", "short": "trace", "color": "#bbc0d0" },
          { "id": "debug", "label": "debug", "short": "debug", "color": "#9aa0b0" },
          { "id": "info",  "label": "info",  "short": "info",  "color": "#5b8af0" },
          { "id": "warn",  "label": "warn",  "short": "warn",  "color": "#d9a973" },
          { "id": "error", "label": "error", "short": "error", "color": "#d97373" },
          { "id": "fatal", "label": "fatal", "short": "fatal", "color": "#d42b2b" }
        ],
        "defaultChip": "info",
        "prefs": {
          "chipFilter": ["trace", "debug", "info", "warn", "error", "fatal"],
          "tags": [],
          "stakeholders": [],
          "search": "",
          "composerChip": "info",
          "tz": "local",
          "recentTz": []
        },
        "createdAt": "2026-09-20T09:00:00Z",
        "entries": [
          {
            "id": "260921.150000", "ts": "2026-09-21T15:00:00Z", "created_at": "2026-09-21T15:00:00Z",
            "chip": "info", "title": "Sender domain added to the gateway deny list",
            "description": "", "tags": ["gateway"], "stakeholders": ["mail-team"],
            "edited_at": null, "archived": false
          },
          {
            "id": "260921.153000", "ts": "2026-09-21T15:30:00Z", "created_at": "2026-09-21T15:30:00Z",
            "chip": "warn", "title": "Deny-list sync to the backup gateway lagged 20 minutes",
            "description": "", "tags": ["gateway"], "stakeholders": [],
            "edited_at": null, "archived": false
          }
        ]
      }
    ],
    "activeWorkspaceId": "ws_payroll",
    "prefs": { "theme": null, "accent": null }
  }
}
```

What the app does with this, as a check on your own reasoning. These
results were produced by importing this exact file into the app:

- The success message is *"Imported 2 workspaces · 8 entries"*.
- Two tabs are added after the user's existing ones, and "Payroll phishing,
  Sept 2026" is the open tab.
- Every entry is stored exactly as written: no id is changed, no chip is
  moved, and no tag is altered.
- Only the two workspace `id`s differ, because import always issues new
  ones.
- The open tab shows five rows. The archived placeholder is hidden, and the
  two 13:22:00 entries sit side by side.
- The staff notice shows an **edited** mark: it was written down at
  14:12:31 about something that happened at 14:00.
- The theme and accent are left as the user had them.
- Exporting straight back out gives the same workspaces, entries, chips and
  prefs, apart from the new workspace `id`s.
