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

This document describes PumaGRC2's `.pumapack` files in enough detail to
**edit an export by hand or generate one from scratch** so that it imports
cleanly: no warnings, no re-keyed scores, no silently 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. PumaGRC2 imports it in any of three ways:

- from the topbar **Import** button;
- with **⌘O** / **Ctrl-O**;
- by dropping the file anywhere on the window.

PumaGRC2 has no separate data-schema number. The only version it writes is
the envelope's `puma.format`, which is `1`; that is the "schema 1" in the
title.

Import **adds** assessments. It never merges into an existing one. The one
exception is an assessment whose `id` already exists in this browser: the app
asks whether to overwrite it (see §2).

---

## 1. The short version

If you only read one section, read this one.

1. Wrap your assessments in the envelope from §2. Import reads only
   `data.assessments` and `data.prefs`, and refuses a file with no `puma`
   object.
2. Give the file a `.pumapack` extension. The extension decides how the file
   is read (§2).
3. Write **every field** of every record, using the shapes in §4. Use `""`,
   `[]`, `false` or `null` exactly where §4 says to. The importer checks the
   assessment's top-level fields and **nothing inside them**.
4. Key every score as `"<framework id>␟<control id>"`, for example
   `"nist-csf-2␟GV.RM-01"`. The separator is the character U+241F. See §5.
5. Use only framework ids from the table in §3.2, and control ids that exist
   in that framework. An unknown framework id is dropped; an unknown control id
   is kept but never shown.
6. Use the exact lower-case enum ids from §4.2: `"not-assessed"`, `"ad-hoc"`,
   `"repeatable"`, `"defined"`, `"managed"`, `"optimized"` for maturity. A
   misspelled value is kept and breaks the counts.
7. List snapshots and activity-log entries **newest first**. Give every
   snapshot all eight fields, including the three statistics (§6.3).
8. Give each assessment an `id` that is unique in the file. If that id already
   exists in the browser, the user is asked whether to overwrite it.
9. Check the result against the checklist in §9.

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

---

## 2. The envelope

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumagrc2",
    "appVersion": "generated",
    "format": 1,
    "exportedAt": "2026-09-28T09:00:00.000Z",
    "title": "Northwind Freight security baseline"
  },
  "data": {
    "assessments": [ { "...one assessment object, see §3..." } ],
    "prefs": { "theme": "dark", "accent": null }
  }
}
```

| Key | Value | Notes |
|---|---|---|
| `$schema` | `"https://pumaworx.dev/pumapack/v1"` | Not checked. Write it; other tools look for it. |
| `puma` | object | **Required.** Its presence is what makes the file a pack. |
| `puma.app` | `"pumagrc2"` | If present and anything else, the import stops (see below). If missing, the file is accepted. Always write it. |
| `puma.appVersion` | any string | Free text. The app writes its build id, or `"dev"`. |
| `puma.format` | `1` | Not checked on import. Write `1`. |
| `puma.exportedAt` | ISO 8601 datetime | Informational. |
| `puma.title` | string | Informational. The app writes `"<assessment name> backup"`. |
| `data` | object | **Required.** |
| `data.assessments` | array of assessment objects | **Required and non-empty.** |
| `data.prefs` | object, optional | The theme and accent color to restore. See §4.5. |

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

| Problem | Message |
|---|---|
| Not valid JSON | *"Import failed: Not a valid JSON file: ..."* followed by the parser's own message |
| No `puma` object (for example, a bare assessment in a `.pumapack` file) | *"Import failed: Missing puma envelope — not a .pumapack file?"* |
| No `data` object | *"Import failed: Missing data payload."* |
| `data.assessments` missing, not an array, or empty | *"Import failed: No assessments in pumapack."* |
| Every entry in `data.assessments` failed the assessment test in §3 | *"Import failed: Pumapack contained no valid assessments."* |
| `puma.app` names another app, e.g. `"pumarisk"` | *"That's a PumaRisk pumapack. Open it in PumaRisk to use it."* Nothing is imported. |

On success the message is *"Imported 1 assessment"* or *"Imported N
assessments"*, the first imported assessment becomes the active tab, and
`data.prefs` is applied.

**The file extension decides how the file is read.**

| Extension | Read as |
|---|---|
| `.pumapack` | The envelope above. A bare assessment is refused. |
| `.json` | Either the envelope above or a bare assessment object (§3). |
| `.csv`, `.tsv` | A score sheet applied to the active assessment. Not covered here. |
| none | JSON if it starts with `{`, otherwise CSV. |

**Two JSON shapes, and which is which.**

- The **full backup** is the envelope above, holding every assessment in the
  browser. It is what the topbar **Export** button and **⌘S** write, named
  `pumagrc2-<name>-<YYYY-MM-DD>.pumapack`. This is the file to hand to an AI,
  and the shape this document leads with.
- The **JSON** export on the assessment tab's right-click menu writes one
  assessment object on its own, with no envelope, as a `.json` file. Import
  accepts it only from a `.json` file, and says *"Imported assessment
  "<name>""*.

The **Send to PumaTracker** export is also a `.pumapack` stamped
`"app": "pumagrc2"`, but it holds PumaTracker tasks, not assessments.
Importing it back into PumaGRC2 fails with *"Import failed: No assessments in
pumapack."*

**Existing ids.** When an imported assessment's `id` matches one already in
the browser, the user sees a confirm dialog:

> An assessment named "..." already exists with the same id.
> OK = overwrite it with the imported copy.
> Cancel = import as a new, separate assessment.

OK replaces the stored assessment with the imported one. Cancel keeps both,
giving the imported one a fresh id. This is the normal path when an edited
export is imported back into the browser it came from.

PumaGRC2 does not read other apps' packs. A pack from a sibling app is
recognized by its `puma.app` and answered with the message in the table above.

---

## 3. The assessment object

An assessment is one workspace tab: a set of enabled frameworks and a score
for each control that has been touched.

```json
{
  "id": "assess_northwind_2026",
  "name": "Northwind Freight · 2026 security baseline",
  "color": "#5b8af0",
  "created_at": "2026-06-30T14:00:00.000Z",
  "updated_at": "2026-09-26T16:30:00.000Z",
  "enabled_frameworks": ["nist-csf-2", "soc2", "hunting-mm"],
  "scores": { },
  "snapshots": [ ]
}
```

### 3.1 Fields

| Field | Type | Notes |
|---|---|---|
| `id` | string | The assessment's identity. Unique in the file. The app generates ids like `assess_mg1abc2d_x7k2pq`; any non-empty string works. If missing or not a string, a new id is generated. |
| `name` | string | Shown on the tab. Missing becomes `"Imported assessment"`. |
| `color` | `#rgb` or `#rrggbb` | The tab's color dot. Missing or `""` becomes `#2ea870`. Any other non-hex value is stored but the dot is drawn in the default blue. |
| `created_at` | ISO 8601 datetime | Kept as written. Missing becomes the import time. |
| `updated_at` | ISO 8601 datetime | **Always replaced with the import time.** Write a real value anyway. |
| `enabled_frameworks` | array of framework ids | Which catalogs are switched on, from §3.2. Unknown ids are removed. If nothing valid remains, or the field is not an array, it becomes `["nist-csf-2", "iso-27001", "soc2"]`. |
| `scores` | object | One score entry per touched control. See §4.1 and §5. If it is not an object, it becomes `{}` and **every score is lost**. |
| `snapshots` | array | Saved history points, newest first. See §4.3. If it is not an array, it becomes `[]`. |
| `_control_meta` | object | Written on export for other apps to read (framework, function and category names and the control description, per scored control). **Removed on import.** Do not write it, and do not bother editing it. |

**What counts as an assessment.** An entry in `data.assessments` is imported
only if it has at least one of: a `scores` object, an `enabled_frameworks`
array, a `snapshots` array, or both a string `id` and a string `name`. Any
other entry is **skipped silently**; the success message counts only the
entries that were kept.

**Unknown keys survive.** Any extra key on the assessment, on a score entry or
on a snapshot is stored and exported again unchanged. It has no effect.

**Framework order.** The sidebar always lists frameworks in the app's own
order, not the array's. When the assessment is opened, the framework shown
is the one the user last viewed if it is enabled, otherwise the first entry
of `enabled_frameworks`.

### 3.2 Framework ids

There are 18 catalogs and 1,621 controls. Control ids are case-sensitive and
must match the catalog exactly.

| Framework id | Name | Controls | Example control ids |
|---|---|---|---|
| `nist-csf-2` | NIST CSF 2.0 | 106 | `GV.OC-01`, `PR.AA-03`, `DE.CM-01` |
| `iso-27001` | ISO 27001:2022 | 93 | `A.5.1`, `A.8.16` |
| `soc2` | SOC 2 (TSC) | 62 | `CC1.1`, `CC6.1`, `CC7.2` |
| `nist-800-53` | NIST SP 800-53 Rev 5 | 241 | `AC-1`, `SI-4` |
| `nist-800-171` | NIST SP 800-171 Rev 3 | 110 | `3.1.1`, `3.14.6` |
| `cmmc` | CMMC 2.0 | 110 | `AC.L2-3.1.1` |
| `hipaa` | HIPAA Security Rule | 55 | `308.a1.i` |
| `pci-dss` | PCI DSS 4.0.1 | 250 | `1.1.1`, `3.1.1` |
| `gdpr` | GDPR | 96 | `Art.5.1a` |
| `nist-pf` | NIST Privacy Framework 1.0 | 74 | `ID.IM-P1` |
| `iso-42001` | ISO/IEC 42001:2023 | 88 | `4.1.1` |
| `nist-sp-800-61-r3` | NIST SP 800-61 Rev. 3 | 74 | `P.GV-01` |
| `iso-27035` | ISO/IEC 27035 | 55 | `PP.1.1` |
| `sans-picerl` | SANS PICERL | 63 | `P.PEOPLE-01` |
| `cis-control-17` | CIS Control 17 | 9 | `17.1` |
| `cisa-ir-playbook` | CISA Federal IR Playbook | 45 | `PREP.1` |
| `soc-cmm` | SOC-CMM 2.3 | 70 | `SOC-B.1-01` |
| `hunting-mm` | Hunting Maturity Model | 20 | `HMM-D.1-01`, `HMM-H.2-02` |

**Getting the full list of control ids.** The catalogs are built into the app
and are not in the pack. To get every id for a framework, enable it in the app
and use **CSV** on the assessment tab's right-click menu: it writes one row per
control with `framework_id`, `subcategory_id` and `description` columns. A
full-backup export also lists, under `_control_meta`, the framework, category
and description of every control that already has a score.

---

## 4. Record shapes

### 4.1 Score entry (`scores[key]`)

One per control that has been touched. A control with no entry reads as Not
Assessed with nothing written, so you only need entries for controls you have
something to say about.

```json
"nist-csf-2␟PR.AA-03": {
  "maturity": "repeatable",
  "priority": "high",
  "compensating": true,
  "proof": "MFA enforced on the VPN and email; the warehouse scanners use shared PINs.",
  "plan": "Replace shared scanner PINs with per-user badges.",
  "notes": "Badge readers on the scanners act as a compensating control until then.",
  "activity_log": [],
  "risk": null
}
```

| Field | Type | Meaning |
|---|---|---|
| `maturity` | enum, §4.2 | The maturity rating, 0 to 5. |
| `priority` | enum, §4.2 | How urgently it needs work. |
| `compensating` | boolean | A compensating control is in place. Shown as ⛨ on the control. |
| `proof` | string | Shown as **Evidence**: policies, screenshots, logs, tool configs. Plain text; `\n` for new lines. |
| `plan` | string | The remediation plan. A non-empty plan counts toward **Plans** on the dashboard and the "plan present" filter. |
| `notes` | string | Free notes. Not editable on the control card; carried into the reports, the CSV, the Excel export and the PumaTracker handoff. |
| `activity_log` | array | Dated notes on the control, newest first. See §4.4. **Must be an array.** |
| `risk` | `null` | Always `null` in new data. Older data may carry an object `{ "likelihood", "impact", "owner", "treatment" }`; it is kept and exported, and PumaRisk reads it, but nothing in PumaGRC2 shows it. |

**Nothing in a score entry is checked or filled in on import.** A missing
field stays missing; a wrong type stays wrong. Write all eight.

### 4.2 Enums

**Maturity.** The number is what the averages use.

| Id | Label | Number | Meaning |
|---|---|---|---|
| `not-assessed` | Not Assessed | 0 | Not yet evaluated. Not counted as assessed. |
| `ad-hoc` | Ad-Hoc | 1 | Reactive, informal, undocumented processes. |
| `repeatable` | Repeatable | 2 | Some processes documented and repeatable. |
| `defined` | Defined | 3 | Standardized, documented policies in place. |
| `managed` | Managed | 4 | Measured, monitored, and controlled. |
| `optimized` | Optimized | 5 | Continuously improving, fully integrated. |

**Priority.**

| Id | Label | Meaning |
|---|---|---|
| `not-set` | Not Set | Not yet prioritized. |
| `high` | High | Urgent; address immediately. Counted as **High priority** on the dashboard. |
| `med` | Med | Important but not urgent. |
| `low` | Low | Address when resources allow. |
| `next` | Next | Scheduled for upcoming work. |
| `working` | Functional | The control is in place and operating. |

Note that the id `working` is labelled **Functional**.

**Enum values are not validated on import.** A misspelling such as
`"Defined"` or `"in-place"` is stored as written. See §8 for what that does.

### 4.3 Snapshot (`snapshots[]`)

A saved point in the assessment's history, for one framework. The History view
draws a maturity trend from them and compares any two.

```json
{
  "id": "snap_nw_q3",
  "label": "Q3 review",
  "created_at": "2026-09-26T16:00:00.000Z",
  "framework_id": "nist-csf-2",
  "scores": { "nist-csf-2␟GV.RM-01": { "maturity": "managed", "...": "..." } },
  "total": 106,
  "assessed": 4,
  "avg_maturity": 0.09433962264150944
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | string | Unique within the assessment. The app generates `snap_...`. |
| `label` | string | Shown in the history table. |
| `created_at` | ISO 8601 datetime | Shown as the snapshot's date. |
| `framework_id` | framework id | The framework the statistics were computed for. The snapshot is listed only while that framework is selected. If missing, it is listed under every framework. |
| `scores` | object | A copy of the whole `scores` map at the time, keyed as in §5. Only each entry's `maturity` is read, to compare two snapshots. |
| `total` | integer | Number of controls in `framework_id` (the "Controls" column of §3.2). |
| `assessed` | integer | How many of those had a maturity other than `not-assessed`. |
| `avg_maturity` | number | Sum of maturity numbers over **all** `total` controls, divided by `total`. Unassessed controls count as 0. |

**The three statistics are stored, not recomputed.** The app shows what you
write. A snapshot without `avg_maturity` stops the History view from
rendering. Compute them as described in §6.3.

### 4.4 Activity-log entry (`activity_log[]`)

```json
{ "id": "log_nw_0001", "text": "Charter re-approved at the September board meeting.",
  "created_at": "2026-09-18T10:00:00.000Z", "resolved": true }
```

| Field | Type | Meaning |
|---|---|---|
| `id` | string | Unique within the control's log; the resolve toggle finds the entry by it. The app generates `log_...`. |
| `text` | string | One note. |
| `created_at` | ISO 8601 datetime | Shown as a relative time ("3 days ago"). |
| `resolved` | boolean | Shown ticked and counted as resolved. |

The app adds new entries at the **front** of the array, and shows them in array
order. Write the newest first.

### 4.5 `data.prefs`

```json
{ "theme": "dark", "accent": null }
```

| Field | Values | On import |
|---|---|---|
| `theme` | `"dark"`, `"light"` or `null` | A non-null value is applied and saved. Anything other than `"light"` means dark. |
| `accent` | `#rgb`, `#rrggbb` or `null` | A hex value becomes the app's accent color. Any other non-null value resets the accent to the default. |

`null`, or leaving `prefs` out, leaves the user's current setting alone. **An
exported backup carries the exporting user's preferences**, so importing one
changes the importer's theme. For a generated pack, write `null` for both
unless you mean to change them.

---

## 5. Score keys and cross-references

### 5.1 Score keys

Every key in `scores`, and in every snapshot's `scores`, is:

```
<framework id> + "␟" + <control id>
```

The separator is **U+241F SYMBOL FOR UNIT SEPARATOR** (`␟`), a printable
character. It is **not** the ASCII control character U+001F. In JSON you can
write it literally or as the escape `␟`; both parse to the same key:

```json
"nist-csf-2␟GV.RM-01"
```

The framework part keeps the scores of different catalogs apart: 73 control
ids appear in more than one catalog (for example `3.1.1` is in both PCI DSS
and NIST SP 800-171, and many ISO 42001 ids repeat in PCI DSS or ISO 27001).

**Older exports use bare keys** such as `"GV.RM-01"`. The importer converts
them:

| Bare id is defined by | Result |
|---|---|
| exactly one framework (enabled ones are preferred) | Renamed to that framework's key. Lossless. |
| two or more enabled frameworks | **Copied** to each framework's key. The app tries to warn that those scores should be re-checked, but the success message replaces the warning at once, so the user does not see it. |
| two or more frameworks, none enabled | Copied to each, silently. |
| no framework | Kept under the bare key. Never shown. |

A generated pack should always use full keys.

### 5.2 References

| From | Field | To |
|---|---|---|
| assessment | `enabled_frameworks[]` | a framework id in §3.2 |
| `scores` key | framework part | a framework id in §3.2. Scores for a framework that is not enabled are kept and appear when it is enabled. |
| `scores` key | control part | a control id in that framework. An id the framework does not have is kept but never shown or counted. |
| snapshot | `framework_id` | a framework id in §3.2 |
| snapshot | `scores` keys | as for `scores` |
| activity-log entry | `id` | unique within its own log |

There are no references between assessments. The cross-framework mappings
shown in the app are built in and are never stored in the pack.

---

## 6. Semantics that trip a generator

### 6.1 Dates

- Every date in the pack is a full ISO 8601 datetime in UTC, for example
  `"2026-09-26T16:00:00.000Z"`. There are no bare `YYYY-MM-DD` dates.
- `updated_at` is always overwritten with the import time. `created_at` and
  every other timestamp are kept.

### 6.2 Ordering

| Array | Order |
|---|---|
| `enabled_frameworks` | The first entry is the default framework when the assessment opens. Otherwise order has no effect. |
| `snapshots` | **Newest first.** The trend chart is drawn in array order, not by date, so an oldest-first list draws the trend backwards. |
| `activity_log` | **Newest first**, as displayed. |
| keys of `scores` | No meaning. |
| `data.assessments` | Tabs are added in this order; the first becomes active. |

### 6.3 Derived figures

Nothing below is stored except the three snapshot statistics.

For one framework, over every control in its catalog:

- **Assessed** is the number of controls whose score entry has a `maturity`
  other than `not-assessed`. A control with no entry is not assessed.
- **Completion** is assessed ÷ total, as a whole percentage.
- **Average maturity** is the sum of the maturity numbers (§4.2) of all
  controls, with unassessed and missing ones counted as 0, divided by the
  total. It is **not** the average of the assessed controls only. Four
  controls scored out of 106 give a small number, and that is correct.
- **High priority** counts entries with `priority: "high"`; **Plans** counts
  entries with a non-empty `plan`; **compensating** counts entries with
  `compensating: true`.

A snapshot's `total`, `assessed` and `avg_maturity` must be these same figures
for its `framework_id`, computed over its own `scores`. Example: CSF has 106
controls; a snapshot with GV.RM-01 managed (4), PR.AA-01 defined (3),
PR.AA-03 repeatable (2) and DE.CM-01 ad-hoc (1) has `total: 106`,
`assessed: 4`, `avg_maturity: 10 / 106 = 0.09433962264150944`.

The dashboard, heatmap, gap analysis, cross-map coloring and reports are all
derived from `scores` at display time.

---

## 7. Editing an existing export

The usual workflow is: export a full backup, edit it, import it back.

**Preserve:**

- Each assessment's `id`. Keep it to replace the original on import (the user
  chooses OK at the prompt in §2). Change it, or have the user choose Cancel,
  to bring the edited copy in beside the original.
- Score keys exactly as exported, separator included.
- `created_at`, snapshot and log `id`s and timestamps.
- Any key you do not recognize. Unknown keys are kept and cost nothing.
- Existing snapshots. They are history; do not rewrite their scores or
  statistics to match current scores.

**Recomputed or replaced by the app:**

- `updated_at` becomes the import time.
- `_control_meta` is removed on import and rebuilt on the next export. Edits to
  it are lost.
- Every dashboard figure (§6.3).

**Never hand-edit:**

- The separator in score keys. Retyping it as a different character creates a
  key the app treats as a bare id (§5.1).
- A snapshot's statistics, unless you recompute all three from its scores.

**When adding things:**

- Adding a score: add an entry under a full key, with all eight fields.
- Enabling a framework: add its id to `enabled_frameworks`.
- Adding a log note: put it at the front of `activity_log` with a new unique
  `id`.
- Adding a snapshot: put it at the front of `snapshots`, copy the current
  `scores` into it, and compute its statistics.

**The export holds every assessment in the browser.** Removing an assessment
from the file does not delete it from the browser on import; import never
deletes anything.

---

## 8. Things that go wrong

| Mistake | What happens |
|---|---|
| A bare assessment with no envelope, in a `.pumapack` file | Refused: *"Import failed: Missing puma envelope — not a .pumapack file?"* The same object in a `.json` file is accepted. |
| Assessments under another key, e.g. `data.workspaces` | Refused: *"Import failed: No assessments in pumapack."* |
| `puma.app` set to another app | *"That's a <App> pumapack. Open it in <App> to use it."* Nothing is imported. |
| An entry in `data.assessments` that fails the test in §3.1 | Skipped silently; the others import. |
| `scores` is `null` or not an object | Becomes `{}`. Every score is lost, and the import still reports success. |
| `snapshots` is `null` or not an array | Becomes `[]`. All history is lost, silently. |
| An unknown framework id in `enabled_frameworks` | Removed. If none are left, the assessment gets NIST CSF 2.0, ISO 27001 and SOC 2 instead. |
| A score key with no `␟`, e.g. `"GV.RM-01"` | Converted as in §5.1. Where the id is in two enabled frameworks, the score is copied to both. |
| A score key with a control id the framework does not have | Kept, never shown, never counted. |
| `maturity` misspelled, e.g. `"Defined"` | Kept. The control shows no maturity label, and is **counted as assessed** with maturity 0, which lowers the average. |
| `priority` misspelled | Kept. The control shows no priority label and falls out of the High-priority count. |
| `activity_log` as a string | The import succeeds; opening that control's card fails and the category view does not render. |
| A snapshot without `avg_maturity` | The import succeeds; the History view fails to render. |
| Snapshots oldest first | The trend chart runs backwards. |
| Two assessments with the same `id` in one file | The second triggers the overwrite prompt against the first. OK leaves only the second, although the message still counts both. |
| An `id` that already exists in the browser | The overwrite prompt in §2. |
| `prefs.theme` or `prefs.accent` set | The importer's theme or accent changes. |
| Edits to `_control_meta` | Discarded. |
| Hoping `updated_at` survives | It is replaced with the import time. |

---

## 9. Checklist before handing a pack over

A pack that passes all of these imports with no warnings and nothing re-keyed.

**Structure**
- [ ] The file name ends in `.pumapack`.
- [ ] The envelope matches §2: a `puma` object with `"app": "pumagrc2"` and
      `"format": 1`, and a non-empty `data.assessments` array.
- [ ] Every assessment has all eight fields of §3, and no `_control_meta`.
- [ ] Every score entry has all eight fields of §4.1, with `activity_log` an
      array and `risk` `null`.
- [ ] Every snapshot has all eight fields of §4.3.
- [ ] Assessment ids are unique in the file; snapshot ids are unique in their
      assessment; log ids are unique in their control.

**References**
- [ ] Every id in `enabled_frameworks` is from §3.2, with no duplicates.
- [ ] Every score key is `<framework id>␟<control id>` with U+241F, and the
      control id exists in that framework.
- [ ] Every framework that has scores is enabled, unless you mean those scores
      to stay hidden.
- [ ] Every snapshot's `framework_id` is a framework id from §3.2.

**Values**
- [ ] Every `maturity` and `priority` is one of the exact ids in §4.2.
- [ ] Every timestamp is a full ISO 8601 datetime.
- [ ] `snapshots` and each `activity_log` are newest first.
- [ ] Each snapshot's `total`, `assessed` and `avg_maturity` are computed as
      in §6.3.
- [ ] `prefs` is `{ "theme": null, "accent": null }` or left out, unless you
      mean to change the user's theme.

---

## 10. A complete example

One assessment with three frameworks enabled: eight scored controls across
all three, a compensating control, notes, two activity-log entries on one
control (newest first), and two NIST CSF snapshots, newest first. It imports
with no warnings.

```json
{
  "$schema": "https://pumaworx.dev/pumapack/v1",
  "puma": {
    "app": "pumagrc2",
    "appVersion": "generated",
    "format": 1,
    "exportedAt": "2026-09-28T09:00:00.000Z",
    "title": "Northwind Freight security baseline"
  },
  "data": {
    "assessments": [
      {
        "id": "assess_northwind_2026",
        "name": "Northwind Freight · 2026 security baseline",
        "color": "#5b8af0",
        "created_at": "2026-06-30T14:00:00.000Z",
        "updated_at": "2026-09-26T16:30:00.000Z",
        "enabled_frameworks": ["nist-csf-2", "soc2", "hunting-mm"],
        "scores": {
          "nist-csf-2␟GV.RM-01": {
            "maturity": "managed", "priority": "working", "compensating": false,
            "proof": "Risk management charter approved by the board, reviewed quarterly.",
            "plan": "", "notes": "",
            "activity_log": [
              { "id": "log_nw_0001", "text": "Charter re-approved at the September board meeting.", "created_at": "2026-09-18T10:00:00.000Z", "resolved": true }
            ],
            "risk": null
          },
          "nist-csf-2␟PR.AA-01": {
            "maturity": "defined", "priority": "next", "compensating": false,
            "proof": "Joiner-mover-leaver process documented in the IAM standard.",
            "plan": "Automate leaver deprovisioning from the HR system by Q1.", "notes": "",
            "activity_log": [], "risk": null
          },
          "nist-csf-2␟PR.AA-03": {
            "maturity": "repeatable", "priority": "high", "compensating": true,
            "proof": "MFA enforced on the VPN and email; the warehouse scanners use shared PINs.",
            "plan": "Replace shared scanner PINs with per-user badges.",
            "notes": "Badge readers on the scanners act as a compensating control until then.",
            "activity_log": [], "risk": null
          },
          "nist-csf-2␟DE.CM-01": {
            "maturity": "ad-hoc", "priority": "high", "compensating": false,
            "proof": "", "plan": "Deploy network sensors at both depots and forward to the SIEM.", "notes": "",
            "activity_log": [
              { "id": "log_nw_0003", "text": "Sensor quote received; waiting on budget sign-off.", "created_at": "2026-09-25T09:30:00.000Z", "resolved": false },
              { "id": "log_nw_0002", "text": "Only the head office firewall logs are collected today.", "created_at": "2026-09-10T15:00:00.000Z", "resolved": false }
            ],
            "risk": null
          },
          "soc2␟CC6.1": {
            "maturity": "defined", "priority": "working", "compensating": false,
            "proof": "Access control policy v3; quarterly access reviews in the GRC tracker.",
            "plan": "", "notes": "", "activity_log": [], "risk": null
          },
          "soc2␟CC7.2": {
            "maturity": "repeatable", "priority": "med", "compensating": false,
            "proof": "", "plan": "Write alert triage runbooks for the top ten SIEM rules.", "notes": "",
            "activity_log": [], "risk": null
          },
          "hunting-mm␟HMM-D.1-01": {
            "maturity": "managed", "priority": "working", "compensating": false,
            "proof": "EDR telemetry on every managed endpoint, 90-day retention.",
            "plan": "", "notes": "", "activity_log": [], "risk": null
          },
          "hunting-mm␟HMM-H.1-01": {
            "maturity": "ad-hoc", "priority": "next", "compensating": false,
            "proof": "", "plan": "Start a monthly hypothesis-driven hunt from the threat profile.", "notes": "",
            "activity_log": [], "risk": null
          }
        },
        "snapshots": [
          {
            "id": "snap_nw_q3",
            "label": "Q3 review",
            "created_at": "2026-09-26T16:00:00.000Z",
            "framework_id": "nist-csf-2",
            "scores": {
              "nist-csf-2␟GV.RM-01": { "maturity": "managed", "priority": "working", "compensating": false, "proof": "", "plan": "", "notes": "", "activity_log": [], "risk": null },
              "nist-csf-2␟PR.AA-01": { "maturity": "defined", "priority": "next", "compensating": false, "proof": "", "plan": "", "notes": "", "activity_log": [], "risk": null },
              "nist-csf-2␟PR.AA-03": { "maturity": "repeatable", "priority": "high", "compensating": true, "proof": "", "plan": "", "notes": "", "activity_log": [], "risk": null },
              "nist-csf-2␟DE.CM-01": { "maturity": "ad-hoc", "priority": "high", "compensating": false, "proof": "", "plan": "", "notes": "", "activity_log": [], "risk": null }
            },
            "total": 106,
            "assessed": 4,
            "avg_maturity": 0.09433962264150944
          },
          {
            "id": "snap_nw_baseline",
            "label": "Baseline",
            "created_at": "2026-06-30T15:00:00.000Z",
            "framework_id": "nist-csf-2",
            "scores": {
              "nist-csf-2␟GV.RM-01": { "maturity": "defined", "priority": "working", "compensating": false, "proof": "", "plan": "", "notes": "", "activity_log": [], "risk": null },
              "nist-csf-2␟PR.AA-01": { "maturity": "repeatable", "priority": "next", "compensating": false, "proof": "", "plan": "", "notes": "", "activity_log": [], "risk": null },
              "nist-csf-2␟PR.AA-03": { "maturity": "ad-hoc", "priority": "high", "compensating": false, "proof": "", "plan": "", "notes": "", "activity_log": [], "risk": null }
            },
            "total": 106,
            "assessed": 3,
            "avg_maturity": 0.05660377358490566
          }
        ]
      }
    ],
    "prefs": { "theme": null, "accent": null }
  }
}
```

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

- The message is *"Imported 1 assessment"* and the assessment opens on NIST
  CSF 2.0.
- NIST CSF 2.0: completion 4% (4 of 106), average maturity 0.1 (10 ÷ 106),
  2 high priority, 3 plans, 1 compensating control.
- SOC 2: completion 3% (2 of 62), 1 plan, no high priority.
- Hunting Maturity Model: completion 10% (2 of 20), average maturity 0.25.
- History, under NIST CSF 2.0, lists *Q3 review* (4% complete) above
  *Baseline* (3%). Comparing Baseline with Q3 review shows 4 changes, all up:
  GV.RM-01 Defined to Managed, PR.AA-01 Repeatable to Defined, PR.AA-03
  Ad-Hoc to Repeatable, DE.CM-01 Not Assessed to Ad-Hoc.
- Stored afterwards, the assessment is identical to the file except
  `updated_at`, which is the import time. Exporting it again gives back the
  same assessment plus a rebuilt `_control_meta`.
