# The `.pumapack` file format (PumaPlanner, schema 6)

This document describes PumaPlanner's `.pumapack` files in enough detail to
**generate one from scratch** that imports cleanly. The app opens the result
with no warnings, no re-numbering and no rescheduling surprises. 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. PumaPlanner imports it in either of two
ways:

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

Import always **adds** projects; it never replaces or merges into an existing
one.

---

## 1. The short version

If you only read one section, read this one.

1. Wrap your project in the envelope from §2. Import reads only
   `data.projects`.
2. Write **every field** of every record, using the shapes in §4. Use `""`,
   `[]`, `false` or `0` for "nothing". **Never write `null`** except for
   `baseline`.
3. Set `"schedule_mode": "draft"`. Give each task a whole number of **working
   days** in `estimate_days` and list what it waits for in `deps`. Leave task
   `start` and `end` as `""`: the plan opens undated, and the reader dates it
   with *Schedule from durations* when the list is right. Use `"auto"` instead
   only when the dates should exist the moment the file is imported. See §6.
4. Set `charter.start_date` to a real date, or the plan re-dates itself from
   "today" every time it is opened.
5. Give every risk, issue, decision and assumption a reference (`R-01`,
   `I-01`, `D-01`, `A-01`, in register order). Set `ref_seq` to the highest
   number used in each register. See §7.
6. Every person's name must appear in `charter.stakeholders`, **spelled
   identically** everywhere it is used. See §8.
7. Every RACI row needs **exactly one `A`** and **at least one `R`**.
8. Every id reference must point at a record that exists in the same project.
   The id reference fields are `phase_id`, `deps`, `objective_id`,
   `criterion_id`, `task_id` and `from_risk`.
9. Check the result against the checklist in §10.

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

---

## 2. The envelope

```json
{
  "format": "pumapack",
  "app": "pumaplanner",
  "app_title": "PumaPlanner",
  "version": "generated",
  "exported_at": "2026-10-28T09:00:00.000Z",
  "schema": 6,
  "data": { "projects": [ { "...one project object, see §3..." } ] }
}
```

| Key | Value | Notes |
|---|---|---|
| `format` | `"pumapack"` | Not checked on import, but write it; tooling checks it. |
| `app` | `"pumaplanner"` | Same. |
| `app_title` | `"PumaPlanner"` | Cosmetic. |
| `version` | any string | Free text with no meaning; the app writes `"dev"`. |
| `exported_at` | ISO 8601 datetime | Informational. |
| `schema` | `6` | The current schema. The importer upgrades older data, but a generated file should be 6. |
| `data.projects` | array of project objects | One or more. **Must be non-empty.** |

What the importer actually requires:

- The file is valid JSON. If not: *"That file is not valid JSON."*
- It contains a non-empty array at `data.projects`, or at a top-level
  `projects`. If not: *"No projects found in that file."*
  - A bare project object **with no `projects` wrapper is rejected**.

Every other envelope key is ignored.

On import:

- Each project's `slug` is kept unless it clashes with a project the user
  already has, in which case it becomes `name-2`, `name-3` and so on.
- The last imported project becomes the active tab.
- The file's `activeSlug`, if present, is ignored.

---

## 3. The project object

```json
{
  "slug": "office-wifi-replacement",
  "name": "Office Wi-Fi replacement",
  "accent_color": "",
  "created_at": "2026-10-28T09:00:00.000Z",
  "updated_at": "2026-10-28T09:00:00.000Z",
  "charter":     { },
  "schedule_mode": "auto",
  "phases":      [ ],
  "tasks":       [ ],
  "objectives":  [ ],
  "criteria":    [ ],
  "tests":       [ ],
  "raci":        [ ],
  "risks":       [ ],
  "issues":      [ ],
  "decisions":   [ ],
  "assumptions": [ ],
  "ref_seq":     { "R": 0, "I": 0, "D": 0, "A": 0 },
  "baseline":    null,
  "baselines":   [ ],
  "status":      { "rag": "auto", "narrative": "", "asks": [] }
}
```

| Field | Type | Notes |
|---|---|---|
| `slug` | string | Lower-case `a-z0-9` and `-`, at most 40 characters. The project's identity; must be unique within the file. |
| `name` | string | Shown on the tab. |
| `accent_color` | `""` or `#rgb` / `#rrggbb` | Tab colour. `""` uses the default. |
| `created_at`, `updated_at` | ISO 8601 datetime | Full timestamps, not bare dates. |
| `charter` | object | See §4.1. |
| `schedule_mode` | `"draft"`, `"auto"` or `"manual"` | See §6. **Always write it.** If it is missing, the app guesses `manual` when any task has a date and `draft` otherwise. |
| `phases` … `assumptions` | arrays | See §4. Use `[]` when empty, **never `null`**. |
| `ref_seq` | object | Keyed by prefix letter, not by register name. See §7. |
| `baseline` | `null` or object | See §4.12. `null` means no dates have been committed to yet, which is normal for a new plan. |
| `baselines` | array | Earlier baselines this one replaced. `[]` for a new plan. |
| `status` | object | See §4.11. |

**Any other key at the project level is silently dropped.** Unknown keys on
records *inside* the project survive, but have no effect.

---

## 4. Record shapes

### Conventions for every record

- **`id`** is any non-empty string, unique within its own array in the project.
  - The app generates ids like `t_mg1abc2d_1f`. Short readable ids (`t1`,
    `ph_plan`) work just as well.
  - Ids need not be unique across projects.
- **Dates** are `"YYYY-MM-DD"` strings, or `""` for none. See §6.
- **Defaults are filled in only for keys that are missing.** An explicit
  `null` *overrides* the default and can break rendering. Omit a key or give
  it a real value; never `null`.
- **Enum values are not validated on import.** A misspelled status is kept,
  and the dropdown shows blank. Use the exact ids below.

### 4.1 `charter`

```json
{
  "goal": "", "problem": "", "sponsor": "", "success_definition": "",
  "start_date": "", "target_date": "", "budget_note": "",
  "scope_in": [], "scope_out": [],
  "stakeholders": [ { "id": "s1", "name": "", "role": "", "org": "" } ],
  "doc_version": "", "prepared_by": "", "approved_by": "", "approved_on": ""
}
```

| Field | Type | Meaning |
|---|---|---|
| `goal` | string | One or two sentences: what the project delivers. |
| `problem` | string | Why it exists. |
| `sponsor` | string | One person's name. Should also appear in `stakeholders`. |
| `success_definition` | string | How you would know it worked. |
| `start_date` | date or `""` | In auto mode the whole plan is scheduled forward from this date. See §6. |
| `target_date` | date or `""` | The date promised. The forecast finish is measured against it. |
| `budget_note` | string | Free text. |
| `scope_in`, `scope_out` | arrays of strings | One item per string. **Must be arrays.** |
| `stakeholders` | array of `{id, name, role, org}` | The roster of people. See §8. |
| `doc_version` | string | e.g. `"1.0"`. |
| `prepared_by`, `approved_by` | string | Names. |
| `approved_on` | date or `""` | |

### 4.2 `phases[]`

```json
{ "id": "ph_plan", "name": "Plan", "note": "",
  "goal": "Know what we are buying before we sign anything",
  "review": "", "horizon": "committed" }
```

- Array order is phase order.
- Phase *n* gives its tasks the WBS numbers `n.1`, `n.2`, and so on.
- **`goal`** is what the phase is for, in a sentence. Shown under the phase
  name on the Plan and beside it in the status report, the Markdown and the
  workbook's Status sheet. Optional; `""` when there is none.
- **`review`** is what the phase taught and what that changed, written when
  it is done. The Plan shows the field once every task in the phase is
  finished (or once a review exists); the status report lists reviews under
  "Phase reviews". On a plan built in stages this is the record the next
  stage is planned from. A generated plan normally ships it as `""`.
- **`horizon`** is `"committed"` or `"outline"`. A committed phase is a
  promise: its tasks are in the forecast finish, the critical path and the
  baseline, and can be overdue. An outline phase is a rough shape for work
  nobody has planned in detail yet: its tasks are still scheduled (with
  their `estimate_days`, so give them rough ones) and drawn hatched on the
  Timeline, but they are left out of the forecast, the critical path, the
  baseline, the slipped-milestone rule and the overdue flags, and the status
  report says "Committed work finishes … ; N outline phases not yet planned
  in detail". A committed task may not depend on an outline task — the Plan
  warns. Missing or unknown values import as `committed`. This is how a plan
  built in stages (rolling-wave) stays honest about its later stages.
- `note` is kept for compatibility and is not shown anywhere.

### 4.3 `tasks[]`

```json
{
  "id": "t1", "phase_id": "ph_plan", "name": "Survey current coverage",
  "owner": "Ana Costa",
  "start": "", "end": "",
  "percent": 0, "status": "todo",
  "estimate_days": 3, "deps": [],
  "milestone": false, "note": "",
  "pinned": false, "actual_start": "", "actual_end": ""
}
```

| Field | Type | Meaning |
|---|---|---|
| `phase_id` | phase id | **Must match a phase**, or the task is invisible on the Plan view while still counting in the totals. |
| `name` | string | The task. |
| `owner` | string | A stakeholder name, or `""`. |
| `start`, `end` | date or `""` | In **auto** mode these are *computed*, so write `""`. In **manual** mode they are the plan. See §6. |
| `percent` | number, 0–100 | Progress. A task with `status: "done"` always counts as 100. |
| `status` | `"todo"` \| `"doing"` \| `"blocked"` \| `"done"` | Displayed as Not started / In progress / Blocked / Done. |
| `estimate_days` | integer > 0, or `""` | Duration in **working days** (Mon–Fri; public holidays are not modelled). "Two weeks" is `10`. Use `""` for milestones and for unestimated work; an unestimated task is scheduled as 1 day. |
| `deps` | array of task ids | Finish-to-start: this task starts the working day after the **latest** of these ends. **Must be an array**; a string here crashes the import. No cycles. |
| `milestone` | boolean | A gate or decision point. It has no duration of its own: it lands on the working day after its dependencies finish, and whatever depends on it starts the working day after that. |
| `note` | string | Private working note, shown behind the row expander and never exported. |
| `pinned` | boolean | Auto mode only. `true` together with a `start` date makes the task start on that date (next working day if it is a weekend). Leave `false` unless a date is genuinely fixed. |
| `actual_start`, `actual_end` | date or `""` | What really happened. In auto mode a real start anchors the task there, and a real finish fixes its end. Leave both `""` for work that has not started. |

**Task array order is display order** within each phase.

Progress that reads consistently:

| State | `status` | `percent` | `actual_start` | `actual_end` |
|---|---|---|---|---|
| Not started | `todo` | `0` | `""` | `""` |
| Under way | `doing` | 1–99 | a date | `""` |
| Blocked | `blocked` | any | usually a date | `""` |
| Finished | `done` | `100` | a date | a date on or after it |

### 4.4 `objectives[]` (proof of concept)

```json
{ "id": "o1", "name": "Coverage everywhere staff work", "weight": 2, "note": "" }
```

`weight` is a positive number giving this objective's relative importance in
the weighted score. Anything that is not a positive number is treated as 1.

### 4.5 `criteria[]`

```json
{
  "id": "c1", "objective_id": "o1",
  "statement": "Signal is -67 dBm or better in every meeting room",
  "target": "-67 dBm", "method": "Walk test with a survey app",
  "verdict": "untested", "evidence": "", "must_have": true,
  "judged_by": "", "judged_on": ""
}
```

| Field | Values |
|---|---|
| `objective_id` | An objective id. **Must match.** |
| `verdict` | `"untested"`, `"met"` (scores 1), `"partial"` (0.5) or `"unmet"` (0). `untested` is left out of the average rather than counted as zero. |
| `must_have` | boolean. A failed must-have forces **No-go** regardless of the average. While any must-have is `untested`, a favourable recommendation is marked *provisional*. |
| `judged_by`, `judged_on` | Who recorded the verdict, and when. `""` while untested. |

The recommendation is derived, never stored. It is based on the weighted
average of judged criteria:

| Weighted average | Recommendation |
|---|---|
| ≥ 0.85 | **Go** |
| ≥ 0.60 | **Go with conditions** |
| below that | **No-go** |
| nothing judged yet | **Insufficient** |

### 4.6 `tests[]`

```json
{
  "id": "x1", "criterion_id": "c1", "title": "Walk test of meeting rooms",
  "scenario": "", "preconditions": "", "steps": "1. ...\n2. ...",
  "expected": "", "actual": "", "status": "notrun", "tester": "", "run_date": ""
}
```

- `criterion_id` must match a criterion.
- `status` is one of `"notrun"`, `"pass"`, `"partial"` or `"fail"`.
- `steps` is free text; use `\n` between steps.
- `tester` is a stakeholder name, or `""`.
- Tests *suggest* a verdict for their criterion in the UI; they never set it.

### 4.7 `raci[]`

```json
{ "id": "r1", "activity": "Choose the vendor",
  "assignments": { "Priya Shah": "A", "Tom Reyes": "R", "Ana Costa": "C" } }
```

- `assignments` is keyed by **person name**, not by id.
- Values are `"R"`, `"A"`, `"C"` or `"I"`, upper case, one letter per person
  per row. Omit a person to leave the cell blank.
- **Rule: exactly one `A` and at least one `R` per row.** A row that breaks it
  is listed as a problem and turns the status amber. See §8.

### 4.8 `risks[]`

```json
{
  "id": "k1", "ref": "R-01",
  "description": "Access-point lead time exceeds four weeks",
  "likelihood": 3, "impact": 4,
  "mitigation": "Order in week one; hold a loan unit from the reseller",
  "owner": "Tom Reyes", "status": "mitigating", "review_date": "2026-11-13"
}
```

| Field | Values |
|---|---|
| `likelihood`, `impact` | Integers 1–5: very low, low, medium, high, very high. **Numbers, not strings.** Score = likelihood × impact. Bands: ≥ 15 critical, ≥ 10 high, ≥ 5 moderate, otherwise low. |
| `status` | `"open"`, `"mitigating"`, `"accepted"`, `"occurred"` or `"closed"`. `occurred` means the risk has happened and now lives in the issue log. Only use it if an issue exists with `from_risk` set to this risk's id. |
| `review_date` | When it will next be looked at. A date in the past on an open risk is flagged as stale. |

### 4.9 `issues[]`

```json
{
  "id": "i1", "ref": "I-01",
  "description": "Ceiling access in the east wing needs facilities approval",
  "raised_on": "2026-10-27", "due_date": "2026-11-20",
  "impact": 3, "owner": "Tom Reyes", "status": "open",
  "resolution": "", "from_risk": "", "task_id": "t4"
}
```

| Field | Values |
|---|---|
| `impact` | Integer 1–5. **Any unresolved issue makes the derived status at least amber; one with impact 4 or 5 makes it red.** |
| `status` | `"open"`, `"resolving"` or `"resolved"`. |
| `due_date` | Target date. An unresolved issue past it is overdue. |
| `from_risk` | The id of the risk this was promoted from, or `""`. |
| `task_id` | The id of the task it is hurting, or `""` for a project-level issue. |

**Write every field.** Issues are the one register where the importer does
*not* fill in a missing `status`, `impact` or `raised_on`.

### 4.10 `decisions[]` and `assumptions[]`

```json
{ "id": "d1", "ref": "D-01", "status": "decided", "date": "2026-10-20",
  "decision": "Standardise on one access-point vendor",
  "rationale": "One management console; spares are interchangeable",
  "decided_by": "Priya Shah",
  "options": "", "needs": "", "decide_by": "", "owner": "", "informed_by": "" }
```

```json
{ "id": "d2", "ref": "D-02", "status": "open",
  "decision": "Which sites go in the second wave",
  "options": "All three (one visit, no rollback)\nThe two closest (rollback stays possible)",
  "needs": "The first wave's fault log after two weeks",
  "decide_by": "2026-11-20", "owner": "Priya Shah", "informed_by": "t7",
  "date": "", "rationale": "", "decided_by": "" }
```

- **`status`** is `"open"` (still to be made) or `"decided"`. A file with no
  `status` on a decision imports every decision as `decided`, because before
  schema 6 decisions could only be logged after they were made. An unknown
  value also imports as `decided`, never as `open`.
- An **open** decision carries the question in `decision`, the `options` in
  play (free text, one per line), what would settle it in `needs`, a
  `decide_by` date and an `owner`. `date`, `rationale` and `decided_by` are
  `""` until it is decided; setting the status to decided in the app stamps
  `date` with that day. An open decision past its `decide_by` date turns the
  status report **amber** and is listed under "Decisions to make".
- **`informed_by`** is one `tasks[].id` or `criteria[].id`, or `""` — the
  task or proof-of-concept criterion whose outcome answers it (a spike is a
  criterion; the decision it feeds names it here). One id, never a list.
- A **decided** decision is the log entry: `date`, `decision`, `rationale`
  and `decided_by`. Keep `options` and `needs` if they were filled in; they
  are the record of what was considered.

```json
{ "id": "a1", "ref": "A-01", "kind": "dependency",
  "text": "Facilities can give ceiling access on a weekend",
  "owner": "Tom Reyes", "check_by": "2026-11-06", "status": "open" }
```

- **`kind`** is `"assumption"` (believed but unchecked) or `"dependency"`
  (needed from outside the project).
- **`status`** is `"open"` (not yet checked), `"confirmed"` or `"broken"` (did
  not hold). A broken one is reported in the status report.
- Unknown `kind` or `status` values fall back to `assumption` / `open`.

### 4.11 `status`

```json
{ "rag": "auto", "narrative": "", "asks": [] }
```

- **`rag`** is `"auto"`, `"green"`, `"amber"` or `"red"`.
  - Use `"auto"`: the app derives the colour and says why.
  - Any other value is a manual override, still shown beside the derived
    answer.
- **`narrative`** is the author's summary paragraph.
- **`asks`** is an **array of strings**: what the sponsor is being asked to
  decide or provide.

### 4.12 `baseline` and `baselines[]`

A baseline records the dates the plan committed to. Without one, nothing can
be "late". A brand-new generated plan normally has `"baseline": null` and the
user takes one in the app.

To ship a plan that is already baselined:

```json
{
  "taken_on": "2026-10-28", "note": "Agreed at kick-off",
  "tasks": { "t1": { "start": "2026-11-02", "end": "2026-11-04" } },
  "target_date": "2026-12-11", "approved_by": "Priya Shah",
  "kind": "initial"
}
```

- `tasks` needs an entry **for every task id**, holding that task's
  committed dates. Those dates must equal the dates the scheduler will
  compute (§6), or every task shows variance on day one.
- In practice it is easier to leave `baseline` as `null`.
- `baselines` holds earlier baselines, each with an extra
  `"replaced_on": "YYYY-MM-DD"`. It is `[]` for a new plan.
- **`kind`** says why the baseline was taken. The oldest baseline is always
  `"initial"` (the importer forces it). Each later one is `"slip"` (the plan
  moved and the commitment followed) or `"increment"` (the planned re-commit
  at the start of the next stage of a plan built in waves). The status report
  counts the two apart — "re-baselined 3 times (1 after a slip, 2 planned)" —
  so a rolling-wave plan is not reported as a plan that keeps slipping. A
  missing or unknown value imports as `""`, reported as "no reason recorded";
  it is never guessed.

---

## 5. Cross-references

All references stay within one project and point at an `id`:

| From | Field | To |
|---|---|---|
| task | `phase_id` | `phases[].id` |
| task | `deps[]` | `tasks[].id` (not itself; no cycles) |
| criterion | `objective_id` | `objectives[].id` |
| test | `criterion_id` | `criteria[].id` |
| issue | `task_id` | `tasks[].id` or `""` |
| issue | `from_risk` | `risks[].id` or `""` |
| decision | `informed_by` | `tasks[].id` or `criteria[].id` or `""` |
| baseline | keys of `tasks` | `tasks[].id` |
| RACI | keys of `assignments` | **names** in `charter.stakeholders` |

A dangling dependency is ignored by the scheduler, so the task silently
schedules as if it had no predecessor. A dependency cycle stops
auto-scheduling entirely and puts a warning on the Plan.

---

## 6. Dates and scheduling

- **Format.** Exactly `YYYY-MM-DD`, e.g. `"2026-11-02"`.
  - `"2026-11-2"`, `"02/11/2026"` and `"2026-11-02T00:00:00Z"` are all treated
    as *no date*.
  - Dates are whole days in UTC, with no times and no time zones.
- **Working days.** Monday to Friday. Durations count working days, and a
  scheduled task never starts or ends on a weekend. The one exception is a
  recorded `actual_start`, which is kept as a fact.

### `schedule_mode: "draft"` (what a new plan is in the app)

No task has a date, and none is worked out until the person schedules the
plan from the Plan view — either from the durations (which turns it `auto`)
or by hand (`manual`). Every task should still carry `estimate_days` and
`deps`, so that scheduling gives a real answer. The critical path is
computed from the estimates; the forecast finish, the Timeline and the
baseline wait. A generated plan may ship as a draft when the reader should
choose when to date it; if the dates should exist on import, use `auto`.

### `schedule_mode: "auto"` (recommended for generated plans that should arrive dated)

On import and on every load the app **recomputes every task's `start` and
`end`** by working through the dependencies in order:

- **Start**, taking the first rule that applies:
  1. `actual_start`, if set;
  2. otherwise, if `pinned` is true and `start` is set, that date (moved to
     the next working day if needed);
  3. otherwise, the working day after the latest `end` among its `deps`;
  4. otherwise, with no deps, the project start: `charter.start_date`, or
     **today** if that is empty.
- **End**:
  - `actual_end`, if set and not before the start;
  - otherwise start + (`estimate_days` − 1) working days;
  - a milestone ends on its start day;
  - an unestimated non-milestone takes 1 day.

So in auto mode:

- write `start` and `end` as `""`, because anything you put there is
  overwritten;
- the durations and dependencies *are* the plan;
- **set `charter.start_date`**, or an untouched plan slides forward one day
  per day.

### `schedule_mode: "manual"`

Nothing is computed. Every task's `start` and `end` are exactly what you
write, so you must make them consistent. For each task:

- `start` ≤ `end`;
- a task starts **after** every task in its `deps` ends;
- every milestone has an `end`.

Each break of those rules is listed on the Plan as a warning. `estimate_days`
is still shown, but it does not move anything.

### Designing a plan that schedules well

- **Sketch the later stages as outline phases.** If the last phases are
  known only in shape, give them `"horizon": "outline"` with rough
  `estimate_days`. The forecast finish then stops at the committed work
  instead of being computed from guesses, and the report says what is not
  yet planned.
- **Chain phases, not tasks.** Inside a phase, work that can run in parallel
  should depend on the phase's entry point, not on each other. A gate
  milestone at the end of the phase depends on all of it, and the next
  phase's tasks depend on that gate.
  - A plan where every task depends on the one before it makes *every* task
    critical. That is arithmetically true, but it tells the reader nothing.
- Give every non-milestone task a realistic `estimate_days`. Use `""` only for
  milestones.

---

## 7. References (`ref`) and `ref_seq`

Risks, issues, decisions and assumptions carry a stable, human-citable
reference:

| Register | Prefix | Example |
|---|---|---|
| `risks` | `R` | `R-01` |
| `issues` | `I` | `I-01` |
| `decisions` | `D` | `D-01` |
| `assumptions` | `A` | `A-01` |

- **Format:** prefix, a hyphen, then a number padded to at least two digits:
  `R-01` … `R-99`, `R-100`.
- **Numbering:** in register (array) order, starting at 1, with no gaps and no
  duplicates.
- **`ref_seq`:** the last number handed out per prefix, keyed by the **letter**:
  `{ "R": 3, "I": 1, "D": 2, "A": 0 }`. The next row added in the app gets the
  following number. Numbers are never reused, even after a delete.
- **What the importer does:**
  - A missing or malformed `ref`, such as `r-1` or `R01`, is **re-stamped**
    with the next free number.
  - `ref_seq` is raised to at least the highest ref present.
  - Duplicate refs are **not** detected; don't write them.

---

## 8. People

There is no separate people table. The app builds its list of people from
every name typed anywhere:

- `charter.stakeholders[].name`
- task `owner`
- risk, issue and assumption `owner`
- test `tester`
- criterion `judged_by`
- decision `decided_by` and `owner`
- every key in `raci[].assignments`

That list feeds every owner dropdown and the **columns of the RACI matrix**.
So:

- **Spell each name identically everywhere.** "Tom Reyes" and "Tom" are two
  people, two RACI columns, and two dropdown entries.
- **List every person in `charter.stakeholders`** with a `role`. It is the
  roster, and it gives each person a role on the Charter.
  - `sponsor`, `prepared_by` and `approved_by` are not read into the list, so
    add those people as stakeholders too.
- Role names instead of personal names (`"Network engineer"`) work fine when
  you are writing a template.
- **RACI:**
  - exactly one `A` per row;
  - at least one `R` per row;
  - every assignment key must be a stakeholder name.

---

## 9. Things that go wrong

| Mistake | What happens |
|---|---|
| A single project with no `data.projects` wrapper | Rejected: *"No projects found in that file."* |
| `null` in place of a string, array or object | It overrides the default. A `null` array, or a `null` element *inside* an array, can abort the import or break rendering. |
| `deps` as a string, e.g. `"t1"` | The import throws part-way through and no toast appears. Always use an array. |
| `scope_in`, `scope_out` or `stakeholders` not an array | The Charter fails to render. |
| An issue without `status` / `impact` / `raised_on` | Kept missing: blank dropdowns, and it is not counted as open. |
| Enum typo, e.g. `"in-progress"` or `"Done"` | Kept as it is. It shows blank and falls out of the logic. |
| `likelihood: "3"` | Tolerated, but write numbers. |
| Dates on tasks in auto mode | Overwritten. Use `pinned` or the actual dates if a date is real. |
| No `charter.start_date` in auto mode | The plan is scheduled from whatever day it is opened. |
| Names that differ by spelling | Extra RACI columns; "unknown person" in any check. |
| A task whose `phase_id` matches no phase | Invisible on the Plan, but still counted. |
| An unknown project-level key | Dropped silently. |
| `task.note` | Kept, but never exported. Don't put deliverable content there. |

---

## 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 envelope matches §2, and `data.projects` is a non-empty array.
- [ ] Every record has every field from §4. No `null`s, except
      `"baseline": null`.
- [ ] Ids are unique within each array.

**References**
- [ ] Every `phase_id`, `deps` entry, `objective_id`, `criterion_id`,
      `task_id` and `from_risk` resolves (§5).
- [ ] The task dependency graph has no cycles.
- [ ] Every risk, issue, decision and assumption has a `ref` in sequence, and
      `ref_seq` equals the count per prefix (§7).
- [ ] Every risk with `status: "occurred"` has an issue whose `from_risk`
      points at it.

**Scheduling**
- [ ] `schedule_mode` is set.
- [ ] Auto mode: every non-milestone has an integer `estimate_days` > 0,
      `start`/`end` are `""`, `charter.start_date` is set, and nothing is
      pinned without a reason.
- [ ] Manual mode: every task has `start` ≤ `end`, starts after all its deps
      end, and every milestone has a date.

**People**
- [ ] Every name used anywhere is in `charter.stakeholders`, spelled
      identically (§8).
- [ ] Every RACI row has exactly one `A` and at least one `R`.

**Values**
- [ ] Every enum value is one of the exact ids listed in §4.
- [ ] Dates are `YYYY-MM-DD`; `created_at`, `updated_at` and `exported_at` are
      full ISO datetimes.

---

## 11. A complete example

A small plan in draft: two phases, parallel work inside the second, a gate
milestone at the end of each phase, a duration on every piece of work, no
dates, and one entry in every register. It imports with no warnings and opens
undated, ready to be scheduled.

```json
{
  "format": "pumapack",
  "app": "pumaplanner",
  "app_title": "PumaPlanner",
  "version": "generated",
  "exported_at": "2026-10-28T09:00:00.000Z",
  "schema": 6,
  "data": {
    "projects": [
      {
        "slug": "office-wifi-replacement",
        "name": "Office Wi-Fi replacement",
        "accent_color": "",
        "created_at": "2026-10-28T09:00:00.000Z",
        "updated_at": "2026-10-28T09:00:00.000Z",
        "charter": {
          "goal": "Replace the office Wi-Fi so every room staff work in has reliable coverage.",
          "problem": "Meeting rooms on the east side drop calls; the current access points are out of support.",
          "sponsor": "Priya Shah",
          "success_definition": "Every meeting room passes a walk test at -67 dBm or better, and no support ticket about Wi-Fi is raised in the first two weeks.",
          "start_date": "2026-11-02",
          "target_date": "2026-12-11",
          "budget_note": "Hardware from the approved refresh budget; install done in-house.",
          "scope_in": ["Access points on all three floors", "Guest network"],
          "scope_out": ["Warehouse coverage", "Wired network changes"],
          "stakeholders": [
            { "id": "s1", "name": "Priya Shah", "role": "Sponsor", "org": "Operations" },
            { "id": "s2", "name": "Tom Reyes", "role": "Project lead", "org": "IT" },
            { "id": "s3", "name": "Ana Costa", "role": "Network engineer", "org": "IT" }
          ],
          "doc_version": "1.0",
          "prepared_by": "Tom Reyes",
          "approved_by": "",
          "approved_on": ""
        },
        "schedule_mode": "draft",
        "phases": [
          { "id": "ph_plan", "name": "Plan", "note": "", "horizon": "committed",
            "goal": "Know exactly what is going where before anything is ordered.", "review": "" },
          { "id": "ph_build", "name": "Install and prove", "note": "", "horizon": "committed",
            "goal": "Every room covered, measured, and signed off by the people who work in it.", "review": "" }
        ],
        "tasks": [
          { "id": "t1", "phase_id": "ph_plan", "name": "Survey current coverage", "owner": "Ana Costa",
            "start": "", "end": "", "percent": 0, "status": "todo", "estimate_days": 3, "deps": [],
            "milestone": false, "note": "", "pinned": false, "actual_start": "", "actual_end": "" },
          { "id": "t2", "phase_id": "ph_plan", "name": "Choose the access-point vendor", "owner": "Tom Reyes",
            "start": "", "end": "", "percent": 0, "status": "todo", "estimate_days": 5, "deps": ["t1"],
            "milestone": false, "note": "", "pinned": false, "actual_start": "", "actual_end": "" },
          { "id": "t3", "phase_id": "ph_plan", "name": "Design approved", "owner": "Priya Shah",
            "start": "", "end": "", "percent": 0, "status": "todo", "estimate_days": "", "deps": ["t2"],
            "milestone": true, "note": "", "pinned": false, "actual_start": "", "actual_end": "" },
          { "id": "t4", "phase_id": "ph_build", "name": "Install access points", "owner": "Ana Costa",
            "start": "", "end": "", "percent": 0, "status": "todo", "estimate_days": 8, "deps": ["t3"],
            "milestone": false, "note": "", "pinned": false, "actual_start": "", "actual_end": "" },
          { "id": "t5", "phase_id": "ph_build", "name": "Tell staff about the change windows", "owner": "Tom Reyes",
            "start": "", "end": "", "percent": 0, "status": "todo", "estimate_days": 1, "deps": ["t3"],
            "milestone": false, "note": "", "pinned": false, "actual_start": "", "actual_end": "" },
          { "id": "t6", "phase_id": "ph_build", "name": "Walk-test every meeting room", "owner": "Ana Costa",
            "start": "", "end": "", "percent": 0, "status": "todo", "estimate_days": 2, "deps": ["t4"],
            "milestone": false, "note": "", "pinned": false, "actual_start": "", "actual_end": "" },
          { "id": "t7", "phase_id": "ph_build", "name": "Go-live signed off", "owner": "Priya Shah",
            "start": "", "end": "", "percent": 0, "status": "todo", "estimate_days": "", "deps": ["t5", "t6"],
            "milestone": true, "note": "", "pinned": false, "actual_start": "", "actual_end": "" }
        ],
        "objectives": [
          { "id": "o1", "name": "Coverage everywhere staff work", "weight": 2, "note": "" },
          { "id": "o2", "name": "No harder to run than today", "weight": 1, "note": "" }
        ],
        "criteria": [
          { "id": "c1", "objective_id": "o1", "statement": "Signal is -67 dBm or better in every meeting room",
            "target": "-67 dBm", "method": "Walk test with a survey app", "verdict": "untested",
            "evidence": "", "must_have": true, "judged_by": "", "judged_on": "" },
          { "id": "c2", "objective_id": "o2", "statement": "The management console signs in with company SSO",
            "target": "SSO sign-in works", "method": "Sign in as an admin", "verdict": "untested",
            "evidence": "", "must_have": false, "judged_by": "", "judged_on": "" }
        ],
        "tests": [
          { "id": "x1", "criterion_id": "c1", "title": "Walk test of meeting rooms",
            "scenario": "Normal working day, rooms in use",
            "preconditions": "All access points installed and adopted",
            "steps": "1. Open the survey app\n2. Record signal in the centre of each meeting room\n3. Note any room below target",
            "expected": "Every room reads -67 dBm or better", "actual": "",
            "status": "notrun", "tester": "Ana Costa", "run_date": "" }
        ],
        "raci": [
          { "id": "r1", "activity": "Choose the vendor",
            "assignments": { "Priya Shah": "A", "Tom Reyes": "R", "Ana Costa": "C" } },
          { "id": "r2", "activity": "Install and test",
            "assignments": { "Tom Reyes": "A", "Ana Costa": "R", "Priya Shah": "I" } }
        ],
        "risks": [
          { "id": "k1", "ref": "R-01", "description": "Access-point lead time exceeds four weeks",
            "likelihood": 3, "impact": 4, "mitigation": "Order in week one; hold a loan unit from the reseller",
            "owner": "Tom Reyes", "status": "mitigating", "review_date": "2026-11-13" }
        ],
        "issues": [
          { "id": "i1", "ref": "I-01", "description": "Ceiling access in the east wing needs facilities approval",
            "raised_on": "2026-10-27", "due_date": "2026-11-20", "impact": 3, "owner": "Tom Reyes",
            "status": "open", "resolution": "", "from_risk": "", "task_id": "t4" }
        ],
        "decisions": [
          { "id": "d1", "ref": "D-01", "status": "decided", "date": "2026-10-20", "decision": "Standardise on one access-point vendor",
            "rationale": "One management console; spares are interchangeable", "decided_by": "Priya Shah",
            "options": "", "needs": "", "decide_by": "", "owner": "", "informed_by": "" }
        ],
        "assumptions": [
          { "id": "a1", "ref": "A-01", "kind": "dependency", "text": "Facilities can give ceiling access on a weekend",
            "owner": "Tom Reyes", "check_by": "2026-11-06", "status": "open" }
        ],
        "ref_seq": { "R": 1, "I": 1, "D": 1, "A": 1 },
        "baseline": null,
        "baselines": [],
        "status": { "rag": "auto", "narrative": "", "asks": [] }
      }
    ]
  }
}
```

What the app makes of this, as a check on your own reasoning. On import it
opens as a draft: every Start and End is a dash, the baseline button is
disabled, and the Report says nothing is scheduled yet. Even so:

- The critical path, from the estimates alone, is t1 → t2 → t3 → t4 → t6 → t7;
  `t5` has slack.
- The derived status is **amber**, and the reason given is the one open
  issue. The missing baseline does not count against it, because nothing is
  under way yet.
- The POC reads *insufficient*: nothing has been judged, and one must-have is
  untested.

Press *Schedule from durations* on the Plan. The preview says seven tasks get
dates and the finish becomes 27 Nov; confirm, and these are the dates, as
replayed through the app's own scheduler over this exact file:

- `t1` runs Mon 2 Nov – Wed 4 Nov.
- `t2` runs Thu 5 – Wed 11 Nov.
- The `t3` gate falls on Thu 12 Nov, the working day after `t2` ends.
- `t4` (8 days) and `t5` (1 day) both start Fri 13 Nov, the working day after
  the gate. `t4` runs to Tue 24 Nov, skipping the weekends; `t5` is done the
  same day.
- `t6` runs Wed 25 – Thu 26 Nov.
- Go-live (`t7`) is Fri 27 Nov, 14 days inside the 11 Dec target.
- The plan is now `auto`: a changed duration moves the dates behind it.

---

## 12. Where this comes from

This document describes PumaPlanner at schema 6. The behaviour is defined in
the app's `index.html`:

| Behaviour | Function |
|---|---|
| Envelope | `buildPack()` |
| Import | `importPack()` |
| Defaults and upgrades | `migrate()` and the `make*()` factories |
| References | `ensureRefs()` |
| Scheduling | `applySchedule()` / `scheduleDates()` |
| People | `people()` |
| RACI rule | `raciIssues()` |
| Plan warnings | `scheduleIssues()` |

If the app's `SCHEMA` constant moves past 5, re-check this document against
`migrate()` before relying on it.
