# The `.pumapack` file format (PumaTracker, schema 4)

This document describes the files PumaTracker exports and imports in enough
detail to **edit an existing export** or **generate a workspace from
scratch** that imports cleanly. The app opens the result with no warnings, no
renamed ids and no dropped data. It is written for a reader, human or AI, who
has no access to the app's source.

PumaTracker imports a file in either of two ways:

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

Both go through the same router, which looks at the file's **contents**, not
its extension (only `.csv` and `.tsv` are routed by name).

---

## 1. The short version

If you only read one section, read this one.

1. Use the **workspace file** from §2.1. It is what **⋯ → Export workspace**
   writes, and the file to hand an AI. A `.pumapack` (§2.2) carries exactly
   the same content in a different wrapper.
2. A workspace is a set of **columns** you define, **views** over them, and
   **rows** (tasks). Write every field shown in §4. Use `null`, `""`, `[]` or
   `false` for "nothing", exactly as the tables say.
3. Every id is a non-empty string, unique within its own list. The importer
   keeps your ids, so references between records survive (§5).
4. A row stores its values in `cells`, keyed by **column id**. A select or
   multiselect cell holds **option ids**, never the option's label (§4.3).
5. Dates in cells are `YYYY-MM-DD`. Timestamps (`created_at`, `updated_at`,
   `completed_at`) are full ISO 8601 datetimes (§6).
6. A done task has its done checkbox `true` **and** a `completed_at`
   timestamp. An archived task also has `"archived": true`.
7. When you change a row in an existing export, **set its `updated_at` to a
   later time**. Otherwise a Merge import keeps the browser's copy and your
   edit is silently discarded (§7).
8. Keep `schema_version` at `4` and leave `settings.archive_purge_days` out
   unless you mean it: it deletes archived rows during the import (§6.4).
9. Check the result against the checklist in §9.

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

---

## 2. The file shapes

PumaTracker reads three JSON shapes. Each holds one workspace except the full
backup, which holds all of them.

| Shape | Written by | Import does | Use it for |
|---|---|---|---|
| Workspace file (§2.1) | **⋯ → Export workspace**, as `pumatracker-<slug>-<date>.json` | Opens the **Import workspace** dialog: new, merge or replace | Editing and generating. **Lead with this one.** |
| `.pumapack` (§2.2) | **Export & close** when deleting a workspace, as `pumatracker-<slug>-<date>.pumapack` | Unwraps it, then opens the same dialog | Moving a workspace between PumaWorx apps |
| Full backup (§2.3) | Topbar **Export** or Ctrl/Cmd+S, as `pumatracker-backup-<date>.json` | **Replaces every workspace** after you type `RESTORE` | Moving a whole browser's data. Not for editing. |

The importer also reads `.pumapack` files from some other PumaWorx apps
(PumaNoter, PumaLogger, PumaGRC2), converting them into a new workspace, and
CSV/TSV through a separate import wizard. Neither is described here.

### 2.1 The workspace file

```json
{
  "pumatracker_workspace_version": 1,
  "exported": "2026-10-05T09:00:00.000Z",
  "source_device_id": "",
  "workspace": { "...the workspace object, see §3..." },
  "rows": [ "...row objects, see §4.3..." ]
}
```

| Key | Value | Notes |
|---|---|---|
| `pumatracker_workspace_version` | `1` | **Required**, and must be the number `1`. This is how the file is recognized. (Files from before the app was renamed carry `pumakeeper_workspace_version`, which is also accepted.) |
| `exported` | ISO 8601 datetime | Informational. Shown in the import dialog. |
| `source_device_id` | string | Ignored on import. The app writes its own device id; `""` is fine. |
| `workspace` | object | **Required.** See §3. It must contain a `columns` array and a `views` array. |
| `rows` | array of rows | The tasks. Missing or not an array means zero rows. |

Every other top-level key is ignored.

### 2.2 The `.pumapack`

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumatracker",
    "appVersion": "generated",
    "format": 1,
    "exportedAt": "2026-10-05T09:00:00.000Z",
    "title": "Office move"
  },
  "data": {
    "workspace": { "...the workspace object from §3, WITHOUT columns..." },
    "columns": [ "...column objects, see §4.1..." ],
    "tasks": [ "...row objects, see §4.3..." ]
  }
}
```

It is the workspace file rearranged: `columns` moves out of the workspace
into `data.columns`, and `rows` is renamed `data.tasks`. Everything inside is
identical.

| Key | Value | Notes |
|---|---|---|
| `puma.format` | `1` | **Required**, the number `1`. Anything else is refused. |
| `puma.app` | `"pumatracker"` | Selects this reader. |
| `puma.exportedAt` | ISO 8601 datetime | Shown in the import dialog. |
| `$schema`, `puma.appVersion`, `puma.title` | strings | Informational. |
| `data.workspace` | object | **Required.** Must contain a `views` array. A `columns` key here is ignored and replaced by `data.columns`. |
| `data.columns` | array | **Required.** |
| `data.tasks` | array of rows | Missing means zero rows. |

### 2.3 The full backup

```json
{
  "pumatracker_backup_version": 1,
  "exported": "2026-10-05T09:00:00.000Z",
  "source_device_id": "",
  "active_workspace_id": "office_move",
  "theme": "dark",
  "accent": null,
  "workspaces": [ { "schema": { "...workspace object with columns..." }, "rows": [ ] } ]
}
```

Restoring one **erases every workspace in the browser** and writes the
backup's workspaces exactly as they are, ids included, with none of the
clean-up §4 describes. The user must type `RESTORE` first; the app then
reports *Restored 1 workspace · 6 rows. Reloading…* and reloads. It is
the right file for moving everything to a new browser and the wrong one for
editing, so the rest of this document is about the workspace file.

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

| Problem | What the user sees |
|---|---|
| Not valid JSON | *"Could not parse JSON: …"* followed by the parser's message |
| None of the three shapes (for example a bare workspace object with no wrapper, or `pumatracker_workspace_version: 2`) | *"Unrecognized file — expected a PumaTracker workspace, full backup, .pumapack, or CSV."* |
| A `.pumapack` whose `puma.format` is not `1` | *"Could not import pumapack: Unsupported .pumapack format: 2"* |
| A `.pumapack` with no `data.workspace`, or `data.columns` not an array | *"Could not import pumapack: Pumapack data missing workspace or columns"* |
| `workspace.columns` or `workspace.views` missing or not an array | The dialog opens (it reports "0 columns" or "0 views"), then clicking **Import** gives *"Import failed: Workspace section is malformed"* |
| File larger than 16 MB | *"File too large (… MB). Max is 16MB."* |

### The Import workspace dialog

A workspace file or `.pumapack` does not import on its own. It opens a dialog
that summarizes the file ("… columns, … views, … rows", the export date) and
asks how to import it. The user then clicks **Import**.

- **No workspace in the browser has the file's `slug`:** the only choice is
  **Create new workspace**. Success reads
  *Imported as new workspace ✓ "office_move" (6 rows)*.
- **A workspace with that `slug` already exists:** three choices, with
  **Merge** selected by default.
  - **Merge**: the file's columns and views replace the local ones; rows are
    matched by `id` and the one with the later `updated_at` wins; rows only
    in the browser are kept. Reads *Merged ✓ rows: 1 added / 1 updated / 5
    unchanged.*
  - **Replace**: the workspace becomes exactly the file. Reads *Replaced
    workspace ✓ (6 rows loaded)*.
  - **Import as a new copy**: as for a new slug, with `_2`, `_3` … appended.

After the import the workspace becomes the active tab.

---

## 3. The workspace object

```json
{
  "id": "ws_office_move",
  "name": "Office move",
  "slug": "office_move",
  "accent_color": "#3b6e8c",
  "schema_version": 4,
  "columns": [ ],
  "views": [ ],
  "active_view_id": "v_all",
  "groups": [],
  "settings": { "palette": ["#3b6e8c", "#5b8af0"], "undo_max": 20, "welcome_seen": true }
}
```

| Field | Type | Notes |
|---|---|---|
| `id` | string | Replaced with a new random id when importing as a new workspace; the local id is kept on merge or replace. Any value works. |
| `name` | string | Shown on the tab. Cut to 120 characters. Missing becomes `"Imported"`. |
| `slug` | string | The workspace's identity in the browser, and what decides between "new" and "merge/replace". Write lower-case `a-z`, `0-9` and `_`. Anything else is converted on a new import (`"Office Move 2026!"` becomes `office_move_2026`). |
| `accent_color` | CSS color | Tab color. Write `#rrggbb`. An unsafe value (such as `url(...)`) becomes `#5b8af0`. |
| `schema_version` | `4` | The current schema. Lower numbers trigger upgrades on load (§7.4). |
| `columns` | array | See §4.1. **Required** (in a `.pumapack` it lives in `data.columns`). |
| `views` | array | See §4.2. **Required.** An empty array gets one "All" view. |
| `active_view_id` | view id | The view shown first. If it matches no view, the first view is used. |
| `groups` | array | A legacy feature. Write `[]`. |
| `settings` | object | Kept exactly as written. See below. Missing gives the default settings. |

`settings` keys:

| Key | Meaning |
|---|---|
| `palette` | Array of `#rrggbb` colors offered for new options. Missing or empty gets an eight-color default. |
| `undo_max`, `welcome_seen` | Carried by exports; they have no effect. `20` and `true` are fine. |
| `archive_purge_days` | `30` or `90` (any positive number works). **Archived rows whose `completed_at` (or `updated_at`) is older than this many days are deleted every time the workspace loads, including during the import itself.** Omit it unless you want that. See §6.4. |

**Any other key on the workspace is dropped** on a new import.

---

## 4. Record shapes

### Conventions for every record

- **`id`** is any non-empty string, unique within its list: column ids among
  columns, option ids within one column, view ids among views, row ids among
  rows. The app generates UUIDs; short readable ids (`c_due`, `r_movers`)
  work just as well. A second record with an id already seen is **dropped**.
- The importer rebuilds every column, option, view and row from the fields
  listed below. **A key not listed is dropped.**
- Free-text fields are cut to 2,000 characters, except `notes_log` (100,000).
  That includes a row's `description`.

### 4.1 `columns[]`

```json
{
  "id": "c_priority", "key": "priority", "label": "Priority",
  "type": "select", "role": "priority",
  "position": 2, "default": null, "pill_style": "pill", "width": null,
  "show_in_sidebar": true,
  "options": [
    { "id": "o_pri_high", "value": "High", "color": "#e05050",
      "position": 0, "archived": false, "aliases": ["high", "hi"] }
  ]
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | string | Referenced by row `cells`, by views, and by filters and sorts. |
| `key` | string | A short machine name, e.g. `"priority"`. Up to 64 characters. Avoid `"status"` together with role `"bucket"`: that pair is re-roled to `"status"` on load. |
| `label` | string | The column header. Missing becomes `"Field"`. |
| `type` | enum | One of the ten types below. **A column of any other type is dropped, with all its cells.** |
| `role` | string or `null` | Optional wiring into app behavior. See the roles table. Not validated. |
| `position` | integer | Display order, from 0. Renumbered 0, 1, 2 … on load, in position order. |
| `default` | see note | The value a new task gets. For `select`, an option id. For `text`, `date`, `url`, `user`, a string. Use `null` for none. **Write `null` for checkbox and number columns**: import turns `false` and `5` into the strings `"false"` and `"5"`, and a string `"false"` makes every new task start checked. |
| `pill_style` | `"pill"` or `"checkbox"` | `"checkbox"` for checkbox columns, `"pill"` for everything else. |
| `width` | number or `null` | Column width in pixels, up to 2000. `null` is automatic. |
| `show_in_sidebar` | boolean | List this column's values as quick filters in the sidebar. Useful for select and multiselect columns. |
| `options` | array or `null` | Only for `select` and `multiselect`; `null` for every other type. |

Exports also carry `quick_add_prefix` (the typed character, such as `!` or
`^`, that routes a quick-add token to this column). **Import currently
discards it**, so an imported workspace has no quick-add prefixes until they
are set again from the column menu. Writing it does no harm.

**Column types, and what a cell of that type holds:**

| `type` | Cell value | Example |
|---|---|---|
| `text` | string | `"Server room"` |
| `longtext` | string, may contain `\n` and Markdown | `"- Switches\n- Printers"` |
| `number` | JSON number | `4200`, `1850.5` |
| `date` | `"YYYY-MM-DD"` | `"2026-10-16"` |
| `checkbox` | `true` or `false` | `true` |
| `url` | string | `"https://example.com/quote"` |
| `user` | a person's name, free text | `"Dana Okafor"` |
| `select` | **one option id** | `"o_pri_high"` |
| `multiselect` | **array of option ids** | `["o_tag_vendor", "o_tag_it"]` |
| `ref` | **array of row ids** in the same workspace | `["r_movers"]` |

**Roles.** A role is optional and connects a column to app behavior. Give
each role to at most one column. The roles the app offers, by type:

| Role | Column type | What it does |
|---|---|---|
| `done` | `checkbox` | The completion checkbox. Ticking it stamps `completed_at`, runs recurrence and announces unblocked tasks. "Archive completed tasks" sweeps rows where it is ticked. |
| `bucket` | `select` | The workflow pipeline (Inbox / Next / Waiting …). New subtasks inherit it. An option whose value is `Inbox` drives the "Inbox: cleared" notice. |
| `status` | `select` | A second workflow column, used beside a bucket. Subtasks inherit it too. |
| `priority` | `select` | Priority pills. An option whose value is exactly `CRIT` gets its own emphasis. |
| `project` | `select` | Classification. |
| `recurrence` | `select` | The repeat cadence. See §6.5. |
| `tags` | `multiselect` | Classification. |
| `due` | `date` | Due date. Past dates are shown as overdue, today's as due today. Recurrence advances it. |
| `assignee` | `user` | Owner. |
| `url` | `url` | The task's link. |

`text`, `longtext`, `number` and `ref` columns take no role (`null`). The
first `ref` column is the one the task card shows as **Dependencies** (under
the column's own label).

**Options** (`select` and `multiselect` only):

| Field | Type | Meaning |
|---|---|---|
| `id` | string | **Required.** An option with no string `id` is dropped. Referenced by cells, column `default` and filters. |
| `value` | string | The label shown. |
| `color` | `#rrggbb` | Pill color. Write six-digit hex: other valid CSS colors are kept but the pill loses its tinted background. |
| `position` | integer | The option's order. Grouped views follow the array order, so keep `position` equal to the array index. |
| `archived` | boolean | Hidden from pickers. A cell that still holds it still shows it, but a grouped view files that row under "(none)". |
| `aliases` | array of strings | Extra words quick-add accepts for this option, lower case. Up to 16. |

### 4.2 `views[]`

```json
{
  "id": "v_urgent", "name": "High priority", "type": "list",
  "filters": [ { "columnId": "c_priority", "op": "eq", "value": "o_pri_high" } ],
  "sort": [ { "columnId": "c_due", "dir": "asc" } ],
  "group_by": null, "group_by_special": null, "sort_special": null,
  "visible_columns": ["c_done", "c_stage", "c_due", "c_owner"],
  "collapsed_groups": [], "expanded_rows": []
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | string | Referenced by `active_view_id`. |
| `name` | string | The view tab's label. |
| `type` | `"list"` | Write `"list"`. (`"board"` is accepted and shown as a list; anything else becomes `"list"`.) |
| `filters` | array | Every filter must pass. See below. |
| `sort` | array of `{columnId, dir}` | `dir` is `"asc"` or `"desc"`. Applied in order; empty values sort last. With no sort, rows follow their `position`. |
| `group_by` | column id or `null` | Section headers by this column's value. For a `select` column, sections follow option order, then a trailing "(none)". |
| `group_by_special` | `"completed_week"` or `null` | Group by the week each task was completed (§6.3). Overrides `group_by`. |
| `sort_special` | `{ "key": "completed_at" \| "created_at", "dir": "asc" \| "desc" }` or `null` | A sort on the row's own timestamps, applied before `sort`. |
| `visible_columns` | array of column ids | The columns shown, in addition to the task name. If this key is missing, every column is shown. |
| `archive_mode` | `"exclude"`, `"include"` or `"only"` | Which archived rows the view shows. **Omit the key** for the usual `"exclude"`. `"only"` makes an archive view. |
| `collapsed_groups` | array | Write `[]`. |
| `expanded_rows` | array of row ids | Rows whose description is shown inline under the name in this view. |

**Filters** are `{ "columnId": <column id>, "op": <op>, "value": <value> }`:

| `op` | `value` | Passes when the cell … |
|---|---|---|
| `eq`, `neq` | a cell value (option id for select) | equals / does not equal it |
| `in`, `not_in` | **array** of values | is / is not in the array |
| `contains` | string | contains it, ignoring case |
| `empty`, `not_empty` | omit or `null` | is empty / is not empty |
| `gt`, `lt` | value | is greater / less (dates compare as text, which works for `YYYY-MM-DD`) |
| `between` | **array of two** values | lies between them, inclusive |
| `has`, `has_not` | one option id | (multiselect) includes / does not include it |

A filter whose column does not exist, whose `op` is not in this table, or
whose `value` has the wrong shape for `in`, `not_in` or `between` is dropped.
A sort entry with an unknown column or a bad `dir` is dropped.
`visible_columns` and `group_by` entries that name no column are dropped.

### 4.3 `rows[]` (tasks)

```json
{
  "id": "r_pack_it", "position": 2,
  "parent_id": null, "group_id": null, "archived": false,
  "name": "Pack the IT equipment",
  "description": "",
  "notes_log": "",
  "created_at": "2026-09-15T09:10:00.000Z",
  "updated_at": "2026-09-15T09:10:00.000Z",
  "completed_at": null,
  "cells": { "c_done": false, "c_stage": "o_st_next", "c_blocked": ["r_movers"] }
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | string | Referenced by `parent_id`, `ref` cells and `expanded_rows`. A missing id is replaced with a random one, which breaks anything that pointed at it. |
| `position` | integer | Order when a view has no sort. Missing means its place in the array. |
| `parent_id` | row id or `null` | Makes this row a subtask of that row. **One level only**: see §6.2. A parent that is not in the file becomes `null`. |
| `group_id` | `null` | A legacy field. Write `null`. |
| `archived` | boolean | Hidden from ordinary views, shown in an `archive_mode: "only"` view. |
| `name` | string | The task title. |
| `description` | string | Markdown, shown in the task card. At most 2,000 characters. |
| `notes_log` | string | The task's running notes, Markdown. The app appends entries as a bracketed local timestamp line, then the note, separated by a blank line: `"[2026-10-02 14:05]\nQuote received."` Any text is accepted. |
| `created_at`, `updated_at` | ISO 8601 datetime | **Write both.** Missing becomes `""`. `updated_at` decides Merge (§7). |
| `completed_at` | ISO 8601 datetime or `null` | When the task was completed. Anything that is not a parseable date becomes `null`. See §6.3. |
| `cells` | object | Keyed by **column id**; values as in the column types table (§4.1). |

In `cells`:

- **Omit a key for an empty cell.** An explicit `null` also reads as empty.
- A key that is not a column id is **dropped**.
- Strings are cut to 2,000 characters; arrays keep their string entries
  only, up to 200. An object value becomes `null`.
- Values are not checked against the column type. A number written as
  `"4200"` stays a string and sorts as text; a select cell holding an option
  label instead of an option id shows blank.

---

## 5. Cross-references

All references stay within one workspace:

| From | Field | Must point at |
|---|---|---|
| row | keys of `cells` | `columns[].id` (unknown keys are dropped) |
| row | a `select` cell | an option `id` in that column |
| row | a `multiselect` cell | option `id`s in that column |
| row | a `ref` cell | `rows[].id` |
| row | `parent_id` | a **top-level** row's `id` (one whose own `parent_id` is `null`) |
| column | `default` (select) | an option `id` in that column |
| workspace | `active_view_id` | `views[].id` |
| view | `visible_columns[]`, `group_by`, filter and sort `columnId` | `columns[].id` |
| view | a filter `value` on a select or multiselect column | option `id`s |
| view | `expanded_rows[]` | `rows[].id` |

What the importer does with a reference that points nowhere:

| Dangling reference | Result |
|---|---|
| cell key, view column, filter, sort | dropped |
| `parent_id` | set to `null`; the row becomes top-level |
| `active_view_id` | the first view is used |
| option id in a cell | **kept**; the cell shows blank |
| row id in a `ref` cell | **kept**; the cell counts it ("(2 refs)") but the task card lists only rows that exist |

---

## 6. Semantics that trip a generator

### 6.1 Dates and times

- **Date cells** are `YYYY-MM-DD`, a calendar date with no time or time
  zone. Other text is kept but is not a date: it is not colored overdue,
  does not recur, and compares as plain text.
- "Overdue" and "due today" compare a `due` cell with **today in the user's
  local time zone**.
- **Timestamps** (`created_at`, `updated_at`, `completed_at`, `exported`)
  are full ISO 8601 datetimes, as `new Date().toISOString()` writes them:
  `"2026-10-05T09:00:00.000Z"`.

### 6.2 Subtasks

A row with `parent_id` set is shown directly under its parent, indented,
whatever the view's sort. Nesting is **one level deep**. The app refuses to
make a subtask of a subtask, and if a file contains one, that row is stored
and counted but **never appears in any view**. Point every `parent_id` at a
top-level row.

### 6.3 Done, completed, archived

- **Done** means the `done`-role checkbox cell is `true`.
- **`completed_at`** is the moment it was ticked. The app stamps it when a
  task is ticked and clears it when it is unticked; **import does neither**,
  so write it yourself: a timestamp on every done row, `null` on every open
  one.
- Archive views group by the Sunday-starting week of `completed_at` ("This
  week", "Last week", "Week of Sep 7"), falling back to `updated_at` when
  `completed_at` is `null`.
- **Archived** is separate from done: `"archived": true` hides a row from
  ordinary views. The app's "Archive completed tasks" sets it on done rows.
  A done row that is not archived stays in the ordinary views.

### 6.4 Auto-purge

If `settings.archive_purge_days` is set, **archived rows older than that
many days are deleted as the workspace loads**, and the import is such a
load. Age is measured from `completed_at`, or `updated_at` when that is
`null`. The import confirmation still reports the row count from the file,
so nothing on screen says rows were removed. Leave the key out of a
generated workspace.

### 6.5 Recurrence

When a task is ticked done, and the workspace has both a `recurrence`-role
column and a `due`-role column, the task's recurrence option is read **by its
`value`, ignoring case**: `Daily`, `Weekly`, `Monthly` or `Yearly` spawns a
copy of the task with the due date moved on by that step. `(none)`, or any
other value, does nothing. Nothing happens on import.

### 6.6 Ordering

- Columns: by `position`.
- Options: by array order; keep `position` equal to the index.
- Views: by array order (the tab order).
- Rows: by the view's sort, then by `position`. Subtasks sit under their
  parent.

---

## 7. Editing an existing export

### 7.1 Preserve

- **Every `id`**: workspace, columns, options, views and rows. Cells, views,
  filters, subtasks and dependencies all refer to them, and Merge matches
  rows by `id`.
- **`slug`**, if you want the file to go back into the same workspace.
  Changing it makes the import a new workspace.
- **`created_at`** on existing rows.
- `description` and `notes_log` text you are not changing. Append to
  `notes_log` rather than rewriting it.

### 7.2 Update when you change something

- **`updated_at` on every row you change**, to a time later than the one in
  the export. Under Merge, a row whose `updated_at` is not later than the
  browser's copy is left as it was, and your edit is discarded without a
  warning.
- **`completed_at`** when you tick or untick a done checkbox (§6.3).
- New rows: a new unique `id`, `position` after the last row, and
  `created_at` / `updated_at` set to now.
- New options: a new `id` unique within the column, `position` at the end.
- Deleting a column: remove its cells and its ids from views. (The importer
  drops them anyway, but a clean file is easier to check.)

### 7.3 Choosing the import mode

- **To apply edits to a workspace the user already has, choose Replace**
  when the file contains the whole workspace. It is the only mode in which
  deleting a row in the file deletes it in the app.
- **Merge** is the default when the slug exists. It adds new rows, updates
  rows with a later `updated_at`, and keeps every browser row the file does
  not mention, so deletions in the file have no effect. Columns and views
  always come from the file.

### 7.4 What the app regenerates or ignores

| Field | On import |
|---|---|
| `workspace.id` | Replaced with a new random id on a new import; the existing id is kept on merge or replace. |
| `exported`, `source_device_id`, `puma.exportedAt` | Informational only. |
| `quick_add_prefix` | Discarded (§4.1). |
| `_depth` | Some exported rows carry `"_depth": 0` or `1`, a display value. Dropped on import; delete it or leave it. |
| column `position` | Renumbered 0, 1, 2 … in position order. |
| `schema_version` below 4 | The workspace is upgraded on load. Below 2, a column with role `description` is removed and its values moved into each row's `description`. Below 3, quick-add prefixes are assigned from roles. Below 4, archive views with no sort or grouping of their own get completion-week grouping, and done rows with no `completed_at` get their `updated_at`. |

---

## 8. Things that go wrong

| Mistake | What happens |
|---|---|
| A bare workspace object, with no `pumatracker_workspace_version` wrapper | Rejected: *"Unrecognized file — expected a PumaTracker workspace, full backup, .pumapack, or CSV."* |
| `workspace.views` (or `columns`) missing or `null` | *"Import failed: Workspace section is malformed"* after clicking Import. |
| `puma.format` not the number `1` in a `.pumapack` | *"Could not import pumapack: Unsupported .pumapack format: …"* |
| An option **label** in a select cell (`"High"` instead of `"o_pri_high"`) | Kept, but the cell shows blank and filters and grouping ignore it. |
| A column `type` outside the ten listed | The column and all its cells are dropped without a message. |
| An option with no `id` | Dropped; cells that used it show blank. |
| A duplicated id | The second record is dropped. |
| An edited row with an unchanged `updated_at`, imported with Merge | The edit is discarded; the toast counts it as "unchanged". |
| A row deleted from the file, imported with Merge | It stays in the app. Use Replace. |
| `settings.archive_purge_days` set | Old archived rows are deleted during the import, and the toast does not say so. |
| A subtask of a subtask | Stored and counted, but shown nowhere. |
| A done row with `completed_at: null` | Grouped in archive views by `updated_at` instead of its real completion time. |
| `default: false` on a checkbox column | Becomes `"false"`, and every new task starts ticked. Use `null`. |
| `default: 5` on a number column | Becomes the string `"5"`. Use `null`. |
| `quick_add_prefix` on a column | Discarded; quick-add tokens for that column stop matching. |
| A column with `key: "status"` and `role: "bucket"` | Its role is changed to `"status"` on load. |
| A number written as a string, `"4200"` | Kept as text; sorts as text. |
| A date not in `YYYY-MM-DD` | Kept as text; not overdue, does not recur. |
| A `description` over 2,000 characters | Cut to 2,000. Put long text in `notes_log` or a `longtext` column (also 2,000). |
| A `ref` cell naming a missing row | Kept and counted in the cell, not listed in the task card. |
| An unsafe `accent_color` or option `color` | Replaced with a default color. |
| An unknown key on any record | Dropped. |

---

## 9. Checklist before handing a file over

A file that passes all of these imports with no warnings, keeps every id,
and shows every row.

**Structure**
- [ ] The wrapper matches §2.1 (`"pumatracker_workspace_version": 1`,
      `workspace`, `rows`), or §2.2 for a `.pumapack`.
- [ ] `workspace.columns` and `workspace.views` are arrays; `views` has at
      least one view.
- [ ] `schema_version` is `4`.
- [ ] Every record has every field from §4, and no keys that are not listed.
- [ ] Ids are unique within each list.

**Columns and cells**
- [ ] Every column `type` is one of the ten in §4.1, and each role is used
      by at most one column.
- [ ] Every option has a string `id`; select and multiselect cells hold
      option ids, not labels.
- [ ] Checkbox and number columns have `"default": null`.
- [ ] Numbers are JSON numbers; dates are `YYYY-MM-DD`; `ref` cells are
      arrays of row ids.

**References**
- [ ] Every reference in §5 resolves.
- [ ] Every `parent_id` points at a top-level row.

**Rows**
- [ ] Every row has `created_at` and `updated_at`.
- [ ] Every done row has a `completed_at`; every open row has `null`.
- [ ] Every row changed from an export has a later `updated_at`.

**Settings**
- [ ] `settings.archive_purge_days` is absent, unless deleting old archived
      rows on import is intended.

---

## 10. A complete example

A small office-move tracker that uses all ten column types, eight of the ten
roles, four
views (including a filtered one and an archive view), a done-and-archived
task, a dependency, a subtask, a note and a recurring task. It imports with
no warnings as a new workspace, and every value is stored exactly as written
apart from `workspace.id`, which a new import replaces.

```json
{
  "pumatracker_workspace_version": 1,
  "exported": "2026-10-05T09:00:00.000Z",
  "source_device_id": "",
  "workspace": {
    "id": "ws_office_move",
    "name": "Office move",
    "slug": "office_move",
    "accent_color": "#3b6e8c",
    "schema_version": 4,
    "columns": [
      { "id": "c_done", "key": "done", "label": "Done", "type": "checkbox", "role": "done",
        "position": 0, "default": null, "pill_style": "checkbox", "width": null,
        "show_in_sidebar": false, "options": null },
      { "id": "c_stage", "key": "stage", "label": "Stage", "type": "select", "role": "bucket",
        "position": 1, "default": "o_st_next", "pill_style": "pill", "width": null,
        "show_in_sidebar": true,
        "options": [
          { "id": "o_st_next", "value": "Next", "color": "#d4a464", "position": 0, "archived": false, "aliases": ["next"] },
          { "id": "o_st_doing", "value": "Doing", "color": "#5b8af0", "position": 1, "archived": false, "aliases": ["doing", "wip"] },
          { "id": "o_st_waiting", "value": "Waiting", "color": "#e07830", "position": 2, "archived": false, "aliases": ["waiting"] }
        ] },
      { "id": "c_priority", "key": "priority", "label": "Priority", "type": "select", "role": "priority",
        "position": 2, "default": null, "pill_style": "pill", "width": null,
        "show_in_sidebar": true,
        "options": [
          { "id": "o_pri_high", "value": "High", "color": "#e05050", "position": 0, "archived": false, "aliases": ["high", "hi"] },
          { "id": "o_pri_medium", "value": "Medium", "color": "#d4a464", "position": 1, "archived": false, "aliases": ["medium", "med"] },
          { "id": "o_pri_low", "value": "Low", "color": "#5ecc94", "position": 2, "archived": false, "aliases": ["low"] }
        ] },
      { "id": "c_due", "key": "due", "label": "Due", "type": "date", "role": "due",
        "position": 3, "default": null, "pill_style": "pill", "width": null,
        "show_in_sidebar": false, "options": null },
      { "id": "c_owner", "key": "owner", "label": "Owner", "type": "user", "role": "assignee",
        "position": 4, "default": null, "pill_style": "pill", "width": null,
        "show_in_sidebar": false, "options": null },
      { "id": "c_tags", "key": "tags", "label": "Tags", "type": "multiselect", "role": "tags",
        "position": 5, "default": null, "pill_style": "pill", "width": null,
        "show_in_sidebar": true,
        "options": [
          { "id": "o_tag_vendor", "value": "vendor", "color": "#a880e8", "position": 0, "archived": false, "aliases": [] },
          { "id": "o_tag_it", "value": "IT", "color": "#4ec9b0", "position": 1, "archived": false, "aliases": ["tech"] }
        ] },
      { "id": "c_blocked", "key": "blocked_by", "label": "Blocked by", "type": "ref", "role": null,
        "position": 6, "default": null, "pill_style": "pill", "width": null,
        "show_in_sidebar": false, "options": null },
      { "id": "c_repeat", "key": "recurrence", "label": "Recurrence", "type": "select", "role": "recurrence",
        "position": 7, "default": null, "pill_style": "pill", "width": null,
        "show_in_sidebar": false,
        "options": [
          { "id": "o_rep_none", "value": "(none)", "color": "#7a7f8e", "position": 0, "archived": false, "aliases": ["none", "off"] },
          { "id": "o_rep_weekly", "value": "Weekly", "color": "#5ecc94", "position": 1, "archived": false, "aliases": ["weekly"] }
        ] },
      { "id": "c_link", "key": "url", "label": "Link", "type": "url", "role": "url",
        "position": 8, "default": null, "pill_style": "pill", "width": null,
        "show_in_sidebar": false, "options": null },
      { "id": "c_cost", "key": "cost", "label": "Cost (USD)", "type": "number", "role": null,
        "position": 9, "default": null, "pill_style": "pill", "width": null,
        "show_in_sidebar": false, "options": null },
      { "id": "c_room", "key": "room", "label": "Room", "type": "text", "role": null,
        "position": 10, "default": null, "pill_style": "pill", "width": 140,
        "show_in_sidebar": false, "options": null },
      { "id": "c_checklist", "key": "checklist", "label": "Checklist", "type": "longtext", "role": null,
        "position": 11, "default": null, "pill_style": "pill", "width": null,
        "show_in_sidebar": false, "options": null }
    ],
    "views": [
      { "id": "v_all", "name": "All tasks", "type": "list",
        "filters": [], "sort": [], "group_by": null,
        "group_by_special": null, "sort_special": null,
        "visible_columns": ["c_done", "c_stage", "c_priority", "c_due", "c_owner", "c_tags", "c_blocked", "c_link", "c_cost", "c_room"],
        "collapsed_groups": [], "expanded_rows": [] },
      { "id": "v_stage", "name": "By stage", "type": "list",
        "filters": [], "sort": [{ "columnId": "c_due", "dir": "asc" }], "group_by": "c_stage",
        "group_by_special": null, "sort_special": null,
        "visible_columns": ["c_done", "c_priority", "c_due", "c_owner", "c_tags"],
        "collapsed_groups": [], "expanded_rows": [] },
      { "id": "v_urgent", "name": "High priority", "type": "list",
        "filters": [{ "columnId": "c_priority", "op": "eq", "value": "o_pri_high" }],
        "sort": [{ "columnId": "c_due", "dir": "asc" }], "group_by": null,
        "group_by_special": null, "sort_special": null,
        "visible_columns": ["c_done", "c_stage", "c_due", "c_owner"],
        "collapsed_groups": [], "expanded_rows": ["r_movers"] },
      { "id": "v_archive", "name": "Archive", "type": "list",
        "filters": [], "sort": [], "group_by": null,
        "group_by_special": "completed_week",
        "sort_special": { "key": "completed_at", "dir": "desc" },
        "visible_columns": ["c_done", "c_stage", "c_priority", "c_owner"],
        "collapsed_groups": [], "archive_mode": "only", "expanded_rows": [] }
    ],
    "active_view_id": "v_all",
    "groups": [],
    "settings": {
      "palette": ["#3b6e8c", "#5b8af0", "#5ecc94", "#d4a464", "#e05050", "#a880e8", "#4ec9b0", "#e07830"],
      "undo_max": 20,
      "welcome_seen": true
    }
  },
  "rows": [
    { "id": "r_lease", "position": 0, "parent_id": null, "group_id": null, "archived": true,
      "name": "Sign the new lease",
      "description": "Five-year lease on floor 3 of the Harbour Street building.",
      "notes_log": "",
      "created_at": "2026-09-01T10:00:00.000Z", "updated_at": "2026-09-12T16:30:00.000Z",
      "completed_at": "2026-09-12T16:30:00.000Z",
      "cells": { "c_done": true, "c_stage": "o_st_doing", "c_priority": "o_pri_high", "c_owner": "Priya Shah" } },
    { "id": "r_movers", "position": 1, "parent_id": null, "group_id": null, "archived": false,
      "name": "Book the movers",
      "description": "Three quotes, then book for the weekend of **17 October**.",
      "notes_log": "[2026-10-02 14:05]\nQuote from Harbour Removals: $4,200, includes crates.",
      "created_at": "2026-09-15T09:00:00.000Z", "updated_at": "2026-10-02T14:05:00.000Z",
      "completed_at": null,
      "cells": { "c_done": false, "c_stage": "o_st_doing", "c_priority": "o_pri_high", "c_due": "2026-10-09",
                 "c_owner": "Dana Okafor", "c_tags": ["o_tag_vendor"], "c_link": "https://example.com/quotes/harbour",
                 "c_cost": 4200 } },
    { "id": "r_pack_it", "position": 2, "parent_id": null, "group_id": null, "archived": false,
      "name": "Pack the IT equipment",
      "description": "",
      "notes_log": "",
      "created_at": "2026-09-15T09:10:00.000Z", "updated_at": "2026-09-15T09:10:00.000Z",
      "completed_at": null,
      "cells": { "c_done": false, "c_stage": "o_st_next", "c_priority": "o_pri_medium", "c_due": "2026-10-16",
                 "c_owner": "Tom Reyes", "c_tags": ["o_tag_it"], "c_blocked": ["r_movers"], "c_room": "Server room",
                 "c_checklist": "- Switches\n- Firewall\n- Printers" } },
    { "id": "r_label", "position": 3, "parent_id": "r_pack_it", "group_id": null, "archived": false,
      "name": "Label every cable",
      "description": "",
      "notes_log": "",
      "created_at": "2026-09-15T09:12:00.000Z", "updated_at": "2026-09-15T09:12:00.000Z",
      "completed_at": null,
      "cells": { "c_done": false, "c_stage": "o_st_next", "c_owner": "Tom Reyes" } },
    { "id": "r_desks", "position": 4, "parent_id": null, "group_id": null, "archived": false,
      "name": "Order the new desks",
      "description": "",
      "notes_log": "",
      "created_at": "2026-09-20T11:00:00.000Z", "updated_at": "2026-09-20T11:00:00.000Z",
      "completed_at": null,
      "cells": { "c_done": false, "c_stage": "o_st_waiting", "c_priority": "o_pri_low", "c_due": "2026-10-23",
                 "c_owner": "Priya Shah", "c_tags": ["o_tag_vendor"], "c_cost": 1850.5 } },
    { "id": "r_plants", "position": 5, "parent_id": null, "group_id": null, "archived": false,
      "name": "Water the office plants",
      "description": "",
      "notes_log": "",
      "created_at": "2026-09-01T10:00:00.000Z", "updated_at": "2026-09-01T10:00:00.000Z",
      "completed_at": null,
      "cells": { "c_done": false, "c_stage": "o_st_next", "c_due": "2026-10-06", "c_owner": "Dana Okafor",
                 "c_repeat": "o_rep_weekly" } }
  ]
}
```

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

- The import dialog reads "12 columns, 4 views, 6 rows" and, in a browser
  with no `office_move` workspace, offers only **Create new workspace**. The
  confirmation is *Imported as new workspace ✓ "office_move" (6 rows)*.
- **All tasks** lists five rows: the lease is archived, so it appears only
  in **Archive**, grouped under the week that began Sunday 6 September.
- "Label every cable" sits indented under "Pack the IT equipment".
- "Pack the IT equipment" shows one dependency, "Book the movers", in its
  task card. Ticking "Book the movers" done announces *Unblocked 1 task:
  Pack the IT equipment*.
- **High priority** shows only "Book the movers", with its description
  expanded under the name.
- Ticking "Water the office plants" done creates a copy due a week later,
  on 2026-10-13.
- A new task gets the Stage **Next**, the column's default.

To send the same workspace as a `.pumapack`, move `workspace.columns` to
`data.columns`, rename `rows` to `data.tasks`, and wrap it as in §2.2. That
file imports identically.
