# Carryall tool definitions: reference for Copilot (core 0.4.0)

Generated by tools/sync-core.mjs from src/definition-reference.md and the example tools. Do not edit by hand.

You write Carryall **tool definitions**: JSON only. The person pastes the definition into the Carryall
tool builder (the blank template `carryall.html` opened in Edge or Chrome), which checks it, previews
it and downloads the finished tool. Never write JavaScript, HTML or CSS for a Carryall tool, and never
invent keys or functions that this reference does not list.

# Tool definition reference

A Carryall tool is defined by a **tool definition**: one JSON object that lists the tool's tables,
columns, calculations and views. There is no JavaScript to write. Open the blank template
(`carryall.html`) in Edge or Chrome to get the **tool builder**. Paste a definition, select
**Check definition**, fix any problems it lists, then **Preview** and **Download** the finished tool.
To change a tool later, open it and choose **More, Change this tool**.

The builder checks everything below and names each problem with a fix. Its **Copy problems for
Copilot** button copies them in a form Copilot can act on. In a code editor, `node tools/check.mjs`
runs the same checks.

A definition can only use what this reference lists. A tool that needs more, such as a custom screen
(a timeline, calendar or board), calculations that need loops or text matching, or actions that change
many rows at once, is built as a JavaScript app instead, with a coding agent such as GitHub Copilot
in VS Code (see `AGENTS.md`). Never work round a missing feature with extra columns or keys that are
not listed here.

## The rules that matter most

1. **JSON only.** Double quotes around every key and text value. No comments, no functions, no `=>`.
2. **Ids.** The tool `id` is lower case words joined by hyphens (`snagging-tracker`). Table and column
   ids are letters and numbers only, starting with a letter, in camelCase (`distributionNodes`,
   `unitCost`). No hyphens or spaces. Never call a column `id`: every row already has one.
3. **Every table** has `id`, `label`, `singular`, `display` (or `displayFormula`) and `columns`.
4. **Calculations are formulas in quotes** (`"formula": "quantity * rate"`). Calculated values are
   never stored, so they never appear in `seed` or `tests` rows.
5. **Text inside a formula uses single quotes**: `status = 'Open'`.
6. **To add up linked rows, use WHERE with `this`**: `SUM(loads.kw WHERE node = this)`. Inside WHERE,
   plain names belong to the rows being added up; `this` is the row the formula belongs to.
7. **No project number, project name, revision, created by, updated by or checked by columns.**
   Every Carryall document already has these built in.
8. **Include a test**: a few rows and the results you expect, so the self-test proves the sums.

## Shape

```text
{
  "id": "snagging-tracker",
  "name": "Snagging tracker",
  "version": "1.0.0",
  "schemaVersion": 1,
  "description": "Defects found on site, who must fix them and when.",
  "settings": [ ...fields... ],
  "tables": [ ...tables... ],
  "views": [ ...views... ],
  "seed": { "tableId": [ ...starting rows... ] },
  "tests": [ ...tests... ],
  "migrations": [ ...only when stored data changes shape... ]
}
```

## Tool properties

| Key | Required | What it is |
|:-|:-|:-|
| `id` | yes | Lower case words joined by hyphens. Files are matched to tools by this id, so never change it once the tool is in use. |
| `name` | yes | The tool's title. |
| `version` | yes | `"1.0.0"` for a new tool. Increase it every time the tool changes (`"1.1.0"`). |
| `schemaVersion` | yes | `1` for a new tool. Increase by 1 only when stored data changes shape, and add a migration. |
| `description` | no | One line shown on the start screen. |
| `locale`, `currency` | no | Defaults `"en-GB"` and `"GBP"`. |
| `fileName` | no | Default file name for downloads, without `.html`. |
| `home` | no | Web address of the hosted copy, such as `https://tools.example.com/snagging-tracker.html`. Saved copies offer to open their data there. Leave it out until the tool is hosted: without it, nothing offers a latest tool. An address on your own computer (`localhost`) is only used while testing from that address. |
| `versionUrl` | no | Web address of a JSON file `{ "version": "1.2.0" }` used to tell saved copies an update exists. |
| `identity` | no | `"azure-swa"` records the signed-in user when hosted on Azure Static Web Apps. |
| `saveInPlace` | no | `true` lets Edge and Chrome save back to the opened file. Default is a download. |
| `properties` | no | `false` switches off the built-in project details, or `{ "required": ["projectNumber"], "firstRevision": "A" }`. |

## Settings

Document-wide values, such as a default response period. A list of fields written exactly like
columns (see below), except that settings cannot be `ref` or `computed`. Formulas read them as
`settings.fieldId`. Show them with a `settings` view.

```json
"settings": [
  { "id": "responseDays", "label": "Default response period (days)", "type": "number", "decimals": 0, "min": 1, "default": 10 }
]
```

## Tables

```json
{
  "id": "snags", "label": "Snags", "singular": "snag", "display": "ref",
  "columns": [ ... ],
  "checks": [ ... ],
  "onSave": [ ... ]
}
```

| Key | What it is |
|:-|:-|
| `id` | camelCase, unique. The key used in formulas and saved data. |
| `label` | Plural name shown on tabs and headings. |
| `singular` | The name for one row, used on buttons such as "Add snag". |
| `display` | The column that names a row when another table links to it. |
| `displayFormula` | Instead of `display`: a formula such as `"code & ' ' & name"`. |
| `columns` | The list of columns. |
| `checks` | Validation rules (see Checks). |
| `onSave` | Values filled in automatically when a row is saved (see onSave rules). |
| `readOnly` | `true` stops rows being added or edited in the tool. |

## Columns

```json
{ "id": "priority", "label": "Priority", "type": "choice", "options": ["Critical", "Major", "Minor"],
  "default": "Major", "required": true, "colors": { "Critical": "red", "Major": "amber", "Minor": "grey" } }
```

Keys every column can have: `id`, `label`, `type`, `required` (true or false), `unique`, `help`
(guidance shown under the field), `hidden` (never shown), `hideInForm`, `mono` (monospace, for
references and drawing numbers), `default` (a fixed value) and `defaultFormula` (a formula such
as `"TODAY()"` or `"NEXTREF(snags.ref, 'SN-001')"`).

| Type | Stores | Extra keys |
|:-|:-|:-|
| `text` | One line of text | `maxLength`, `suggest`, `suggestFrom` |
| `longtext` | Several lines of text | |
| `number` | A number | `min`, `max`, `decimals`, `step` |
| `currency` | Money as a plain number, such as 1234.5 | `min`, `max`, `currency` |
| `percent` | A percentage stored as 15 for 15% | `min`, `max`, `decimals` |
| `date` | A date written `"YYYY-MM-DD"` | |
| `boolean` | `true` or `false`, shown as a tick box | `checkLabel` |
| `choice` | One of a fixed list | `options` (required), `colors` |
| `ref` | A link to a row in another table (it stores that row's id) | `table` (required), `display` |
| `computed` | Nothing: it is calculated every time | `formula` (required), `format`, `decimals`, `colors`, `colorFormula` |

- `colors` maps values to pill colours: grey, green, amber, red, blue, purple, teal, pink. Pills
  always show their text, so colour is never the only signal.
- `format` sets how a calculated value is shown: number, integer, currency, percent, date, text or
  boolean. Without it, Carryall works it out from the formula.
- `colorFormula` (calculated columns) returns a colour name, such as `"IF(overdue > 0, 'red', '')"`.
- A `ref` column's `display` shows one column of the linked row in tables, such as `"display": "ref"`.
- `"suggest": true` on a text column offers the values already typed in that column as the person types
  (trimmed, each value once, in A to Z order). Any other value can still be typed. `suggestFrom` adds
  the values of other text or choice columns, written `"table.column"`. Use it for names and
  organisations that repeat, rather than a `choice` column whose list would keep growing:

```json
{ "id": "actionBy", "label": "Action by", "type": "text", "suggest": true,
  "suggestFrom": ["comments.raisedByOrg", "responses.organisation"] }
```

## Formulas

Formulas are like spreadsheet formulas, written without the `=`.

| Where | The formula can use |
|:-|:-|
| `formula`, `colorFormula`, `displayFormula` of a table's column | That row's columns, linked rows, settings, table totals |
| `where` of a table, summary, sheet or matrix view | The row being tested |
| `checks[].errorIf` | The row being saved |
| `onSave[].when` and `onSave[].formula` | The row being saved, and `before.column` for its value before the edit |
| `defaultFormula`, KPI `formula`, test `formula` | Settings, `TODAY()`, `USER()` and table totals, but no row |

**Values**: numbers (`12.5`), text in single quotes (`'Open'`), `TRUE`, `FALSE`, `BLANK()`.

**Operators**: `+ - * / ^`, `&` joins text, `%` after a number divides by 100, comparisons
`= <> < > <= >=`, and `AND`, `OR`, `NOT`. `status IN ('Open', 'Responded')` and `NOT IN` test a list.
Text comparisons ignore upper and lower case.

**Columns and links**: a column's id gives its value (`quantity * rate`). Follow a `ref` column with a
dot to read the linked row: `site.name`, `review.reviewed.code`. `id` is the row's own id.

**Settings**: `settings.responseDays`.

**Table totals**: `SUM`, `COUNT`, `AVERAGE`, `MIN` and `MAX` work over another table, optionally
filtered with `WHERE`:

```text
COUNT(snags)                                          all rows
COUNT(snags WHERE status = 'Open')                    rows that match
SUM(loads.kw WHERE node = this)                       rows linked to this row
SUM(nodes.totalKw WHERE suppliedFrom = this)          a tree: child rows of this row
AVERAGE(comments.daysOpen WHERE review.stage = 'Stage 3 Spatial Coordination')
```

Inside WHERE, plain names are the columns of the rows being counted; `this` is the row that owns the
formula, and `this.column` reads its columns. Write `node = this`, never `node = id`.

**Blanks**: blank counts as 0 in sums. Comparing a blank with `<`, `>`, `<=` or `>=` is always false, so
`due < TODAY()` is false while `due` is empty. `status = ''` and `ISBLANK(status)` test for blank.
Dividing by zero gives a blank.

**Dates** are text such as `'2026-09-01'`. Add or take away days with `+` and `-` (`raised + 14`); two
dates taken apart give days (`TODAY() - raised`). Compare dates with `<` and `>`.

**Functions**

| Group | Functions |
|:-|:-|
| Logic | `IF(test, then, else)`, `IFS(test1, value1, test2, value2, ...)`, `SWITCH(value, match1, result1, ..., default)`, `AND(...)`, `OR(...)`, `NOT(x)`, `ISBLANK(x)`, `IFBLANK(x, fallback)`, `COALESCE(a, b, ...)`, `BLANK()` |
| Numbers | `ROUND(x, places)`, `ROUNDUP`, `ROUNDDOWN`, `CEILING(x, step)`, `FLOOR(x, step)`, `INT`, `ABS`, `SQRT`, `POWER(x, y)`, `MOD(x, y)`, `MIN(a, b, ...)`, `MAX(a, b, ...)`, `SUM(a, b, ...)`, `AVERAGE(a, b, ...)` |
| Text | `CONCAT(a, b, ...)`, `LEN`, `UPPER`, `LOWER`, `TRIM`, `LEFT(text, n)`, `RIGHT(text, n)`, `CONTAINS(text, part)`, `VALUE(text)` |
| Dates | `TODAY()`, `DAYS(end, start)`, `YEARFRAC(start, end)`, `EDATE(date, months)`, `ADDDAYS(date, days)`, `YEAR`, `MONTH`, `DAY`, `DATE(year, month, day)` |
| Tables | `SUM`, `COUNT`, `AVERAGE`, `MIN`, `MAX` as above; `NEXTREF` gives the next reference (see below) |
| Other | `USER()` is the name of the person using the tool |

**Next reference**: `NEXTREF(table.column)` continues from the references already in the column. It
takes the most recently added reference that ends in a number, and adds 1 to the highest number used
with the same prefix, keeping the number of digits: `ARUP-09` gives `ARUP-10`, `C-099` gives `C-100`.
While the table is empty it gives blank, or the first value if you add one:
`NEXTREF(comments.ref, 'C-001')`. `NEXTREF(table.column, 'PREFIX-', digits)` still works, and always
uses that prefix: `NEXTREF(snags.ref, 'SN-', 3)` gives SN-004 after SN-003. Use NEXTREF in a
column's `defaultFormula`.

Not available: SUMIF, COUNTIF, VLOOKUP, XLOOKUP and similar. Use `SUM(table.column WHERE ...)`,
`COUNT(table WHERE ...)` and links (`site.name`) instead.

## Checks

Rules that stop a row being saved while it is wrong. `errorIf` is a formula that is true when the row is
wrong; `message` says what to do; `field` (optional) puts the message next to that field.

```json
"checks": [
  { "errorIf": "status = 'Closed' AND ISBLANK(response)", "field": "response", "message": "Add the response before closing the comment." }
]
```

Required, unique, `min` and `max` are checked automatically; no rule is needed for them.

## onSave rules

Values filled in automatically when a row is saved, applied in order. Each sets one `field` to a fixed
`value` or a calculated `formula`, optionally only `when` a formula is true. `before.column` is the
value before this edit (blank for a new row).

```json
"onSave": [
  { "when": "status = 'Closed' AND ISBLANK(closed)", "field": "closed", "formula": "TODAY()" },
  { "when": "status <> 'Closed'", "field": "closed", "value": null },
  { "when": "NOT(ISBLANK(response)) AND ISBLANK(before.response)", "field": "status", "value": "Responded" }
]
```

## Views

Views are the tabs, in order. Without `views`, each table gets a plain table view.

**table**: a searchable, sortable list. Select a row to edit it.

```json
{ "type": "table", "title": "Snags", "table": "snags", "columns": ["ref", "area", "status", "due"],
  "sort": { "column": "ref", "dir": "asc" }, "filters": ["area", "status"], "totals": ["cost"],
  "where": "status <> 'Closed'" }
```

Optional: `columns` (default all), `sort`, `filters` (drop-down filters), `totals` (number columns),
`where`, `add: false` (no Add button), `readOnly: true`, `search: false`, `preset` (values for new
rows, such as `{ "status": "Open" }`), and:

- `quickEdit`: choice columns that can be changed straight from the table, such as `["status"]`.
  Selecting the value opens a short list of the options; choosing one saves the row as the form
  would, so checks, onSave rules and undo all apply. If a check fails, the form opens to show why.
- `hiddenColumns`: columns that start hidden. A Columns button lets people show them (and hide
  others). The choice is kept until the browser tab is closed. List them in `columns` too to set
  where they appear; otherwise they go at the end.
- `open`: the title of a sheet view of the same table. Selecting a row then shows it on that sheet
  tab instead of opening the form, and an edit button at the start of each row opens the form.

```json
{ "type": "table", "title": "Reviews", "table": "reviews", "columns": ["ref", "title", "status", "due"],
  "open": "Review sheet", "quickEdit": ["status"], "hiddenColumns": ["due"] }
```

**summary**: rows grouped by a column, with a bar chart and a table of totals.

```json
{ "type": "summary", "title": "Cost by area", "table": "snags", "groupBy": "area",
  "metrics": [{ "label": "Snags", "op": "count" }, { "label": "Cost", "op": "sum", "column": "cost" }],
  "where": "status <> 'Closed'" }
```

`op` is count, sum, avg, min or max. `bucket: "month"` or `"year"` groups a date column. `chart: false`
hides the chart, or `chart: 1` charts the second metric. `showTable: false` hides the table.
`sortBy: "label"` sorts groups by name.

**kpi**: headline figures. Each `formula` is a table total or other document-level formula.

```json
{ "type": "kpi", "title": "Figures", "items": [
  { "label": "Open snags", "formula": "COUNT(snags WHERE status = 'Open')", "format": "integer" },
  { "label": "Cost to fix", "formula": "SUM(snags.cost WHERE status <> 'Closed')", "format": "currency" }
] }
```

**dashboard**: several views on one tab. `blocks` holds kpi, summary, table, sheet and matrix views;
kpi blocks and blocks with `"wide": true` span the full width.

```text
{ "type": "dashboard", "title": "Overview", "blocks": [ { "type": "kpi", ... }, { "type": "summary", ... } ] }
```

**settings**: a form for the tool's settings. Optional `fields` (setting ids) and `intro` text.

**sheet**: one record at a time, with its linked rows listed underneath and a Print button. Good for
comment sheets, schedules and reports.

```json
{ "type": "sheet", "title": "Comment sheet", "table": "reviews", "fields": ["title", "stage", "due", "status"],
  "sort": { "column": "ref", "dir": "asc" },
  "child": { "table": "comments", "link": "review", "columns": ["ref", "priority", "comment", "response", "status"],
             "sort": { "column": "ref", "dir": "asc" } } }
```

`child.link` is the `ref` column in the child table that points to this view's table.

`"picker": false` hides the list for choosing a record and shows a Back button instead, which returns
to the table the person came from. Use it with a table view whose `open` names this sheet, so the
table is where records are chosen:

```json
{ "type": "sheet", "title": "Review sheet", "table": "reviews", "picker": false, "fields": ["title", "status"] }
```

**matrix**: a cross-tab. `rows` and `columns` are columns (or formulas, often links followed with a dot)
whose values label the rows and columns. Each cell shows the metrics; select it to see the rows.

```json
{ "type": "matrix", "title": "Who reviews whom", "table": "comments", "rows": "review.reviewed",
  "columns": "review.reviewing", "where": "status <> 'Withdrawn'",
  "metrics": [{ "label": "Open", "op": "count", "where": "status IN ('Open', 'Responded')" }, { "label": "Total", "op": "count" }] }
```

## Seed: starting rows

Rows every new document starts with, such as a list of disciplines. Give a row an `id` when other seed
rows link to it. Leave calculated columns out.

```json
"seed": {
  "areas": [ { "id": "gf", "name": "Ground floor" }, { "id": "ff", "name": "First floor" } ],
  "snags": [ { "ref": "SN-001", "area": "gf", "description": "Scuffed door", "status": "Open" } ]
}
```

## Tests

Tests prove the calculations. The self-test loads each test's rows and checks the results. Give rows
an `id` so expectations can name them. Columns with a fixed `default` are filled in automatically.

```json
"tests": [
  { "name": "Overdue flag", "settings": { "dueDays": 14 },
    "tables": {
      "areas": [ { "id": "a1", "name": "Ground floor" } ],
      "snags": [ { "id": "s1", "ref": "SN-001", "area": "a1", "description": "Scuffed door", "raised": "2020-01-01", "status": "Open" } ]
    },
    "expect": [
      { "table": "snags", "row": "s1", "field": "due", "equals": "2020-01-15" },
      { "table": "areas", "row": "a1", "field": "open", "equals": 1 },
      { "table": "snags", "rows": 1 },
      { "formula": "COUNT(snags WHERE overdue = 'Overdue') = 1" }
    ] }
]
```

An expectation is `{ "table", "row", "field", "equals" }` (add `"tolerance": 0.01` for rounding),
`{ "table", "rows" }` for a row count, or `{ "formula" }` that must be true.

## Changing a tool that is in use

- Always increase `version`. Never change `id`.
- Adding a table, a column or a view needs nothing else.
- Renaming, removing or retyping a column or table changes the **shape** of stored data. Increase
  `schemaVersion` by 1 and add a migration for the new number, so files saved by the old tool upgrade
  when they are opened. Never edit or remove an existing migration.
- Add a test with `"schemaVersion"` set to the old number and rows in the old shape, to prove the
  upgrade.
- A migration must turn the same old file into the same new data wherever it is opened, so that
  copies upgraded by different people can still be combined. `TODAY()` and `USER()` cannot be used in
  migration steps. Rows that `textToRef` creates get the same id in every copy.

```json
"migrations": [
  { "to": 2, "description": "Locations become a Sites table", "steps": [
    { "op": "textToRef", "table": "assets", "field": "location", "to": "site", "target": "sites", "match": "name", "blankValue": "Unassigned" }
  ] },
  { "to": 3, "steps": [
    { "op": "renameField", "table": "assets", "from": "value", "to": "cost" },
    { "op": "setField", "table": "assets", "field": "status", "formula": "IF(disposed, 'Disposed', 'In use')", "when": "ISBLANK(status)" },
    { "op": "removeField", "table": "assets", "field": "disposed" }
  ] }
]
```

| Step | Keys | Does |
|:-|:-|:-|
| `renameField` | `table`, `from`, `to` | Renames a column in every row. |
| `removeField` | `table`, `field` | Deletes a column's values. |
| `setField` | `table`, `field`, `value` or `formula`, optional `when` | Sets a column in every row (or rows where `when` is true). Formulas here read the old row's columns only. |
| `renameTable` | `from`, `to` | Renames a table. |
| `mapValues` | `table`, `field`, `map` | Replaces values, such as `{ "Yes": "Approved" }`, for renamed options. |
| `textToRef` | `table`, `field`, `to`, `target`, `match`, optional `blankValue` | Turns free text into links: finds or creates a `target` row whose `match` column equals the text. |

A migration with `"steps": []` is fine when nothing needs converting.

## Built in to every tool

Project number and name, created by, updated by, checked by (with a data fingerprint), revisions,
undo, recovery of unsaved work, CSV import and export, printing, the saved-copy upgrade flow, and
combining copies that several people edited (More, Combine with another copy). Do not add columns
for these.

## Wording in the tool

Sentence case. Plain UK English. Button labels are verbs. Help text and check messages say exactly
what to do. No em dashes, no emoji. Never rely on colour alone.

## Worked example: design review tracker

Three linked tables, seeded rows, formulas across links, checks, onSave rules, a dashboard, a matrix and
a printable sheet. A complete, valid definition.

```json
{
  "id": "design-review-tracker",
  "name": "Design Review Tracker",
  "version": "1.2.0",
  "schemaVersion": 1,
  "description": "Discipline design reviews for a building project: review packages, comments, responses and close-out.",
  "locale": "en-GB",
  "fileName": "design-review-tracker",
  "settings": [
    {
      "id": "responseDays",
      "label": "Default response period (days)",
      "type": "number",
      "decimals": 0,
      "min": 1,
      "default": 10,
      "help": "Sets the \"responses due\" date on new reviews."
    },
    {
      "id": "dueSoonDays",
      "label": "Flag as \"due soon\" within (days)",
      "type": "number",
      "decimals": 0,
      "min": 0,
      "default": 5
    },
    {
      "id": "asAt",
      "label": "Report date",
      "type": "date",
      "help": "Overdue and days-open are measured to this date. Leave blank for today."
    }
  ],
  "tables": [
    {
      "id": "disciplines",
      "label": "Disciplines",
      "singular": "discipline",
      "displayFormula": "TRIM(code & ' ' & name)",
      "columns": [
        {
          "id": "code",
          "label": "Code",
          "type": "text",
          "required": true,
          "unique": true,
          "mono": true,
          "help": "Short code, for example AR, ST or ME."
        },
        { "id": "name", "label": "Name", "type": "text", "required": true },
        { "id": "organisation", "label": "Organisation", "type": "text", "suggest": true },
        {
          "id": "lead",
          "label": "Discipline lead",
          "type": "text",
          "suggest": true,
          "suggestFrom": ["reviews.reviewer", "comments.raisedBy", "comments.respondedBy"]
        },
        {
          "id": "toAnswer",
          "label": "Open comments to answer",
          "type": "computed",
          "format": "integer",
          "formula": "COUNT(comments WHERE status IN ('Open', 'Responded') AND review.reviewed = this)"
        },
        {
          "id": "raised",
          "label": "Comments raised",
          "type": "computed",
          "format": "integer",
          "formula": "COUNT(comments WHERE review.reviewing = this)"
        }
      ]
    },
    {
      "id": "reviews",
      "label": "Reviews",
      "singular": "review",
      "displayFormula": "TRIM(ref & ' ' & title)",
      "columns": [
        {
          "id": "ref",
          "label": "Ref",
          "type": "text",
          "required": true,
          "unique": true,
          "mono": true,
          "defaultFormula": "NEXTREF(reviews.ref, 'DR-', 2)"
        },
        {
          "id": "title",
          "label": "Title",
          "type": "text",
          "required": true,
          "help": "For example \"Stage 3 structural review of architectural GAs\"."
        },
        {
          "id": "stage",
          "label": "Stage",
          "type": "choice",
          "options": [
            "Stage 2 Concept Design",
            "Stage 3 Spatial Coordination",
            "Stage 4 Technical Design",
            "Stage 5 Manufacturing & Construction"
          ]
        },
        {
          "id": "reviewed",
          "label": "Package by",
          "type": "ref",
          "table": "disciplines",
          "required": true,
          "help": "The discipline whose deliverables are being reviewed (they respond)."
        },
        {
          "id": "reviewing",
          "label": "Reviewed by",
          "type": "ref",
          "table": "disciplines",
          "required": true
        },
        {
          "id": "reviewer",
          "label": "Reviewer",
          "type": "text",
          "defaultFormula": "USER()",
          "suggest": true,
          "suggestFrom": ["disciplines.lead", "comments.raisedBy", "comments.respondedBy"]
        },
        {
          "id": "documents",
          "label": "Documents reviewed",
          "type": "longtext",
          "help": "Drawing or document numbers and revisions, one per line."
        },
        { "id": "issued", "label": "Package issued", "type": "date", "defaultFormula": "TODAY()" },
        {
          "id": "due",
          "label": "Responses due",
          "type": "date",
          "defaultFormula": "TODAY() + IFBLANK(settings.responseDays, 10)"
        },
        {
          "id": "status",
          "label": "Status",
          "type": "choice",
          "options": ["Planned", "In review", "Comments issued", "Closed"],
          "default": "In review",
          "colors": { "Planned": "grey", "In review": "blue", "Comments issued": "purple", "Closed": "green" }
        },
        {
          "id": "comments",
          "label": "Comments",
          "type": "computed",
          "format": "integer",
          "formula": "COUNT(comments WHERE review = this AND status <> 'Withdrawn')"
        },
        {
          "id": "open",
          "label": "Open",
          "type": "computed",
          "format": "integer",
          "formula": "COUNT(comments WHERE review = this AND status IN ('Open', 'Responded'))"
        },
        {
          "id": "closedPct",
          "label": "Closed",
          "type": "computed",
          "format": "percent",
          "decimals": 0,
          "formula": "IF(comments > 0, ROUND(100 * (comments - open) / comments, 0), BLANK())"
        },
        {
          "id": "overdue",
          "label": "Overdue",
          "type": "computed",
          "format": "integer",
          "colorFormula": "IF(overdue > 0, 'red', '')",
          "formula": "IF(due < IFBLANK(settings.asAt, TODAY()), COUNT(comments WHERE review = this AND status = 'Open'), 0)"
        }
      ]
    },
    {
      "id": "comments",
      "label": "Comments",
      "singular": "comment",
      "display": "ref",
      "columns": [
        {
          "id": "ref",
          "label": "Ref",
          "type": "text",
          "required": true,
          "unique": true,
          "mono": true,
          "defaultFormula": "NEXTREF(comments.ref, 'RC-0001')"
        },
        {
          "id": "review",
          "label": "Review",
          "type": "ref",
          "table": "reviews",
          "required": true,
          "display": "ref",
          "mono": true
        },
        {
          "id": "document",
          "label": "Document / drawing",
          "type": "text",
          "mono": true,
          "suggest": true
        },
        {
          "id": "location",
          "label": "Location / clause",
          "type": "text",
          "help": "Gridline, level, room, sheet zone or specification clause."
        },
        {
          "id": "topic",
          "label": "Topic",
          "type": "choice",
          "options": [
            "Coordination",
            "Compliance",
            "Buildability",
            "Design quality",
            "Health & safety",
            "Sustainability",
            "Cost",
            "Other"
          ]
        },
        {
          "id": "priority",
          "label": "Priority",
          "type": "choice",
          "options": ["Critical", "Major", "Minor"],
          "required": true,
          "default": "Major",
          "colors": { "Critical": "red", "Major": "amber", "Minor": "grey" },
          "help": "Critical: must resolve before the next stage or issue. Major: resolve this stage. Minor: advisory."
        },
        { "id": "comment", "label": "Comment", "type": "longtext", "required": true },
        {
          "id": "raisedBy",
          "label": "Raised by",
          "type": "text",
          "defaultFormula": "USER()",
          "suggest": true,
          "suggestFrom": ["reviews.reviewer", "disciplines.lead"]
        },
        { "id": "raised", "label": "Raised", "type": "date", "defaultFormula": "TODAY()" },
        { "id": "response", "label": "Design team response", "type": "longtext" },
        {
          "id": "action",
          "label": "Response type",
          "type": "choice",
          "options": ["Agreed", "Agreed in part", "Not agreed", "Clarification"]
        },
        {
          "id": "respondedBy",
          "label": "Responded by",
          "type": "text",
          "suggest": true,
          "suggestFrom": ["disciplines.lead"]
        },
        { "id": "responded", "label": "Responded", "type": "date" },
        {
          "id": "status",
          "label": "Status",
          "type": "choice",
          "options": ["Open", "Responded", "Closed", "Withdrawn"],
          "default": "Open",
          "required": true,
          "colors": { "Open": "blue", "Responded": "purple", "Closed": "green", "Withdrawn": "grey" },
          "help": "Open: awaiting response. Responded: awaiting reviewer. Closed: accepted by reviewer."
        },
        {
          "id": "closed",
          "label": "Closed",
          "type": "date",
          "help": "Set automatically when the comment is closed or withdrawn."
        },
        {
          "id": "actionBy",
          "label": "Action by",
          "type": "computed",
          "format": "text",
          "formula": "IFS(status = 'Open', review.reviewed.code, status = 'Responded', review.reviewing.code, TRUE, '')"
        },
        {
          "id": "due",
          "label": "Response due",
          "type": "computed",
          "format": "date",
          "formula": "review.due"
        },
        {
          "id": "flag",
          "label": "Flag",
          "type": "computed",
          "format": "text",
          "colors": { "Overdue": "red", "Due soon": "amber" },
          "formula": "IFS(status <> 'Open' OR ISBLANK(due), '', DAYS(due, IFBLANK(settings.asAt, TODAY())) < 0, 'Overdue', DAYS(due, IFBLANK(settings.asAt, TODAY())) <= IFBLANK(settings.dueSoonDays, 5), 'Due soon', TRUE, '')"
        },
        {
          "id": "daysOpen",
          "label": "Days open",
          "type": "computed",
          "format": "integer",
          "formula": "IF(ISBLANK(raised), BLANK(), DAYS(IFBLANK(closed, IFBLANK(settings.asAt, TODAY())), raised))"
        }
      ],
      "checks": [
        {
          "errorIf": "status = 'Responded' AND ISBLANK(response)",
          "field": "response",
          "message": "Add the response before marking it responded."
        },
        {
          "errorIf": "status = 'Closed' AND priority = 'Critical' AND ISBLANK(response)",
          "field": "response",
          "message": "Critical comments need a recorded response to close."
        }
      ],
      "onSave": [
        {
          "when": "NOT(ISBLANK(response)) AND ISBLANK(before.response) AND status = 'Open'",
          "field": "status",
          "value": "Responded"
        },
        {
          "when": "NOT(ISBLANK(response)) AND ISBLANK(responded)",
          "field": "responded",
          "formula": "TODAY()"
        },
        {
          "when": "NOT(ISBLANK(response)) AND ISBLANK(respondedBy) AND status <> 'Open'",
          "field": "respondedBy",
          "formula": "USER()"
        },
        {
          "when": "status IN ('Closed', 'Withdrawn') AND ISBLANK(closed)",
          "field": "closed",
          "formula": "TODAY()"
        },
        { "when": "status NOT IN ('Closed', 'Withdrawn')", "field": "closed", "value": null }
      ]
    }
  ],
  "views": [
    {
      "type": "dashboard",
      "title": "Overview",
      "blocks": [
        {
          "type": "kpi",
          "items": [
            {
              "label": "Open comments",
              "formula": "COUNT(comments WHERE status = 'Open')",
              "format": "integer"
            },
            {
              "label": "Awaiting reviewer",
              "formula": "COUNT(comments WHERE status = 'Responded')",
              "format": "integer"
            },
            {
              "label": "Overdue",
              "formula": "COUNT(comments WHERE flag = 'Overdue')",
              "format": "integer"
            },
            {
              "label": "Critical still open",
              "formula": "COUNT(comments WHERE priority = 'Critical' AND status IN ('Open', 'Responded'))",
              "format": "integer"
            },
            {
              "label": "Closed",
              "format": "percent",
              "decimals": 0,
              "formula": "IF(COUNT(comments WHERE status <> 'Withdrawn') > 0, 100 * COUNT(comments WHERE status = 'Closed') / COUNT(comments WHERE status <> 'Withdrawn'), BLANK())"
            }
          ]
        },
        {
          "type": "summary",
          "title": "Open comments by who must act",
          "table": "comments",
          "groupBy": "actionBy",
          "metrics": [{ "label": "Open", "op": "count" }],
          "where": "status IN ('Open', 'Responded')"
        },
        {
          "type": "summary",
          "title": "Open comments by priority",
          "table": "comments",
          "groupBy": "priority",
          "metrics": [
            { "label": "Open", "op": "count" },
            { "label": "Avg days open", "op": "avg", "column": "daysOpen", "format": "integer" }
          ],
          "where": "status IN ('Open', 'Responded')"
        },
        {
          "type": "summary",
          "title": "All comments by status",
          "table": "comments",
          "groupBy": "status",
          "metrics": [{ "label": "Comments", "op": "count" }]
        },
        {
          "type": "summary",
          "title": "Open comments by topic",
          "table": "comments",
          "groupBy": "topic",
          "metrics": [{ "label": "Open", "op": "count" }],
          "where": "status IN ('Open', 'Responded')"
        },
        {
          "type": "summary",
          "title": "Comments raised by month",
          "table": "comments",
          "groupBy": "raised",
          "bucket": "month",
          "metrics": [{ "label": "Raised", "op": "count" }],
          "wide": true
        }
      ]
    },
    {
      "type": "table",
      "title": "Comments",
      "table": "comments",
      "columns": [
        "ref",
        "review",
        "document",
        "location",
        "priority",
        "comment",
        "status",
        "actionBy",
        "due",
        "flag",
        "daysOpen"
      ],
      "hiddenColumns": ["topic", "raisedBy", "raised", "respondedBy", "closed"],
      "quickEdit": ["status", "priority"],
      "sort": { "column": "ref", "dir": "asc" },
      "filters": ["review", "actionBy", "priority", "status", "flag"]
    },
    {
      "type": "table",
      "title": "Reviews",
      "table": "reviews",
      "open": "Comment sheet",
      "columns": [
        "ref",
        "title",
        "stage",
        "reviewed",
        "reviewing",
        "issued",
        "due",
        "status",
        "comments",
        "open",
        "closedPct",
        "overdue"
      ],
      "quickEdit": ["status"],
      "sort": { "column": "ref", "dir": "asc" },
      "filters": ["stage", "status"],
      "totals": ["comments", "open", "overdue"]
    },
    {
      "type": "matrix",
      "title": "Matrix",
      "table": "comments",
      "rows": "review.reviewed",
      "columns": "review.reviewing",
      "where": "status <> 'Withdrawn'",
      "metrics": [
        { "label": "Open", "op": "count", "where": "status IN ('Open', 'Responded')" },
        { "label": "Total", "op": "count" }
      ]
    },
    {
      "type": "sheet",
      "title": "Comment sheet",
      "table": "reviews",
      "picker": false,
      "sort": { "column": "ref", "dir": "asc" },
      "fields": [
        "title",
        "stage",
        "reviewed",
        "reviewing",
        "reviewer",
        "issued",
        "due",
        "status",
        "open",
        "comments",
        "documents"
      ],
      "child": {
        "table": "comments",
        "link": "review",
        "sort": { "column": "ref", "dir": "asc" },
        "columns": [
          "ref",
          "document",
          "location",
          "priority",
          "comment",
          "response",
          "action",
          "status",
          "closed"
        ]
      }
    },
    {
      "type": "table",
      "title": "Disciplines",
      "table": "disciplines",
      "columns": ["code", "name", "organisation", "lead", "toAnswer", "raised"],
      "sort": { "column": "code", "dir": "asc" }
    },
    {
      "type": "settings",
      "title": "Settings",
      "intro": "Project number, name, revisions and checking are under the document line beneath the title."
    }
  ],
  "seed": {
    "disciplines": [
      { "id": "ar", "code": "AR", "name": "Architecture" },
      { "id": "st", "code": "ST", "name": "Structural Engineering" },
      { "id": "cv", "code": "CV", "name": "Civil Engineering" },
      { "id": "me", "code": "ME", "name": "Mechanical Services" },
      { "id": "el", "code": "EL", "name": "Electrical Services" },
      { "id": "ph", "code": "PH", "name": "Public Health" },
      { "id": "fi", "code": "FI", "name": "Fire Engineering" },
      { "id": "fa", "code": "FA", "name": "Facade Engineering" },
      { "id": "ac", "code": "AC", "name": "Acoustics" },
      { "id": "la", "code": "LA", "name": "Landscape" },
      { "id": "su", "code": "SU", "name": "Sustainability" },
      { "id": "pd", "code": "PD", "name": "Principal Designer (CDM)" }
    ]
  },
  "tests": [
    {
      "name": "Overdue, closed share and who must act",
      "tables": {
        "disciplines": [
          { "id": "d1", "code": "AR", "name": "Architecture" },
          { "id": "d2", "code": "ST", "name": "Structural Engineering" }
        ],
        "reviews": [
          {
            "id": "r1",
            "ref": "DR-01",
            "title": "Stage 3 review",
            "reviewed": "d1",
            "reviewing": "d2",
            "due": "2020-01-10",
            "status": "Comments issued"
          }
        ],
        "comments": [
          {
            "id": "c1",
            "ref": "RC-0001",
            "review": "r1",
            "priority": "Critical",
            "comment": "Transfer beam clashes with duct.",
            "status": "Open",
            "raised": "2020-01-01"
          },
          {
            "id": "c2",
            "ref": "RC-0002",
            "review": "r1",
            "priority": "Minor",
            "comment": "Label gridlines.",
            "status": "Closed",
            "raised": "2020-01-01",
            "closed": "2020-01-05",
            "response": "Done"
          }
        ]
      },
      "expect": [
        { "table": "comments", "row": "c1", "field": "flag", "equals": "Overdue" },
        { "table": "comments", "row": "c1", "field": "actionBy", "equals": "AR" },
        { "table": "comments", "row": "c2", "field": "daysOpen", "equals": 4 },
        { "table": "reviews", "row": "r1", "field": "closedPct", "equals": 50 },
        { "table": "reviews", "row": "r1", "field": "overdue", "equals": 1 },
        { "table": "disciplines", "row": "d1", "field": "toAnswer", "equals": 1 },
        { "formula": "NEXTREF(comments.ref) = 'RC-0003'" },
        { "formula": "NEXTREF(reviews.ref, 'DR-01') = 'DR-02'" }
      ]
    }
  ]
}
```

## Worked example: asset register

A tool that has been changed twice since release: schemaVersion 3 with two migrations and a test for
each old version. A complete, valid definition.

```json
{
  "id": "asset-register",
  "name": "Asset Register",
  "version": "1.3.0",
  "schemaVersion": 3,
  "description": "Equipment across sites, with cost, age and straight-line book value.",
  "locale": "en-GB",
  "currency": "GBP",
  "fileName": "asset-register",
  "settings": [
    { "id": "owner", "label": "Register owner", "type": "text" },
    {
      "id": "asAt",
      "label": "Valuation date",
      "type": "date",
      "help": "Book values are calculated to this date. Leave blank to use today."
    },
    {
      "id": "defaultLife",
      "label": "Default useful life (years)",
      "type": "number",
      "decimals": 0,
      "min": 1,
      "default": 5,
      "help": "Used when an asset has no useful life of its own."
    }
  ],
  "tables": [
    {
      "id": "sites",
      "label": "Sites",
      "singular": "site",
      "display": "name",
      "columns": [
        { "id": "name", "label": "Name", "type": "text", "required": true, "unique": true },
        {
          "id": "region",
          "label": "Region",
          "type": "choice",
          "options": ["North", "Midlands", "South", "Scotland", "Wales"]
        },
        { "id": "manager", "label": "Manager", "type": "text" },
        {
          "id": "assets",
          "label": "Assets",
          "type": "computed",
          "format": "integer",
          "formula": "COUNT(assets WHERE site = this AND status <> 'Disposed')"
        },
        {
          "id": "bookValue",
          "label": "Book value",
          "type": "computed",
          "format": "currency",
          "formula": "SUM(assets.bookValue WHERE site = this)"
        }
      ]
    },
    {
      "id": "assets",
      "label": "Assets",
      "singular": "asset",
      "displayFormula": "TRIM(tag & ' ' & name)",
      "columns": [
        {
          "id": "tag",
          "label": "Asset tag",
          "type": "text",
          "required": true,
          "unique": true,
          "mono": true
        },
        { "id": "name", "label": "Name", "type": "text", "required": true },
        {
          "id": "category",
          "label": "Category",
          "type": "choice",
          "options": ["IT", "Furniture", "Vehicle", "Plant", "Other"]
        },
        { "id": "site", "label": "Site", "type": "ref", "table": "sites", "required": true },
        { "id": "purchased", "label": "Purchased", "type": "date", "defaultFormula": "TODAY()" },
        { "id": "cost", "label": "Cost", "type": "currency", "min": 0 },
        {
          "id": "lifeYears",
          "label": "Useful life (yrs)",
          "type": "number",
          "decimals": 0,
          "min": 1,
          "help": "Leave blank to use the default from Settings."
        },
        {
          "id": "status",
          "label": "Status",
          "type": "choice",
          "options": ["In use", "In storage", "Under repair", "Disposed"],
          "default": "In use",
          "colors": { "In use": "green", "In storage": "blue", "Under repair": "amber", "Disposed": "grey" }
        },
        { "id": "notes", "label": "Notes", "type": "longtext" },
        {
          "id": "ageYears",
          "label": "Age (yrs)",
          "type": "computed",
          "format": "number",
          "decimals": 1,
          "formula": "IF(ISBLANK(purchased), BLANK(), MAX(0, YEARFRAC(purchased, IFBLANK(settings.asAt, TODAY()))))"
        },
        {
          "id": "bookValue",
          "label": "Book value",
          "type": "computed",
          "format": "currency",
          "formula": "IF(status = 'Disposed' OR ISBLANK(cost), 0, ROUND(cost * MAX(0, 1 - IFBLANK(ageYears, 0) / IFBLANK(lifeYears, IFBLANK(settings.defaultLife, 5))), 2))"
        },
        {
          "id": "replaceBy",
          "label": "Replace by",
          "type": "computed",
          "format": "date",
          "formula": "IF(ISBLANK(purchased) OR status = 'Disposed', BLANK(), EDATE(purchased, ROUND(IFBLANK(lifeYears, IFBLANK(settings.defaultLife, 5)) * 12, 0)))"
        },
        {
          "id": "endOfLife",
          "label": "End of life",
          "type": "computed",
          "format": "text",
          "colors": { "Past end of life": "red" },
          "formula": "IF(replaceBy <= IFBLANK(settings.asAt, TODAY()), 'Past end of life', '')"
        }
      ]
    }
  ],
  "views": [
    {
      "type": "dashboard",
      "title": "Overview",
      "blocks": [
        {
          "type": "kpi",
          "items": [
            {
              "label": "Assets in service",
              "formula": "COUNT(assets WHERE status <> 'Disposed')",
              "format": "integer"
            },
            {
              "label": "Original cost",
              "formula": "SUM(assets.cost WHERE status <> 'Disposed')",
              "format": "currency"
            },
            { "label": "Book value", "formula": "SUM(assets.bookValue)", "format": "currency" },
            { "label": "Sites", "formula": "COUNT(sites)", "format": "integer" }
          ]
        },
        {
          "type": "summary",
          "title": "Book value by site",
          "table": "assets",
          "groupBy": "site",
          "metrics": [
            { "label": "Book value", "op": "sum", "column": "bookValue" },
            { "label": "Assets", "op": "count" }
          ],
          "where": "status <> 'Disposed'"
        },
        {
          "type": "summary",
          "title": "Assets by category",
          "table": "assets",
          "groupBy": "category",
          "metrics": [{ "label": "Assets", "op": "count" }, { "label": "Cost", "op": "sum", "column": "cost" }],
          "where": "status <> 'Disposed'"
        },
        {
          "type": "summary",
          "title": "Spend by purchase year",
          "table": "assets",
          "groupBy": "purchased",
          "bucket": "year",
          "metrics": [{ "label": "Cost", "op": "sum", "column": "cost" }, { "label": "Assets", "op": "count" }],
          "wide": true
        }
      ]
    },
    {
      "type": "table",
      "title": "Assets",
      "table": "assets",
      "columns": ["tag", "name", "category", "site", "purchased", "cost", "ageYears", "bookValue", "status"],
      "sort": { "column": "tag", "dir": "asc" },
      "totals": ["cost", "bookValue"],
      "filters": ["category", "site", "status"]
    },
    {
      "type": "table",
      "title": "Sites",
      "table": "sites",
      "sort": { "column": "name", "dir": "asc" },
      "totals": ["assets", "bookValue"]
    },
    {
      "type": "dashboard",
      "title": "Replacement plan",
      "blocks": [
        {
          "type": "kpi",
          "items": [
            {
              "label": "Past end of life",
              "formula": "COUNT(assets WHERE endOfLife <> '')",
              "format": "integer"
            },
            {
              "label": "Cost to replace them",
              "formula": "SUM(assets.cost WHERE endOfLife <> '')",
              "format": "currency"
            }
          ]
        },
        {
          "type": "summary",
          "title": "Replacement cost by year due",
          "table": "assets",
          "groupBy": "replaceBy",
          "bucket": "year",
          "metrics": [{ "label": "Cost", "op": "sum", "column": "cost" }, { "label": "Assets", "op": "count" }],
          "where": "NOT(ISBLANK(replaceBy))"
        },
        {
          "type": "table",
          "title": "Due soonest",
          "table": "assets",
          "columns": ["tag", "name", "site", "replaceBy", "cost", "endOfLife"],
          "sort": { "column": "replaceBy", "dir": "asc" },
          "where": "NOT(ISBLANK(replaceBy))",
          "add": false,
          "search": false
        }
      ]
    },
    { "type": "settings", "title": "Settings", "intro": "Values that apply to the whole register." }
  ],
  "migrations": [
    {
      "to": 2,
      "description": "Free-text locations become rows in a new Sites table.",
      "steps": [
        {
          "op": "textToRef",
          "table": "assets",
          "field": "location",
          "to": "site",
          "target": "sites",
          "match": "name",
          "blankValue": "Unassigned"
        }
      ]
    },
    {
      "to": 3,
      "description": "\"value\" is renamed \"cost\"; the \"disposed\" flag becomes a status.",
      "steps": [
        { "op": "renameField", "table": "assets", "from": "value", "to": "cost" },
        {
          "op": "setField",
          "table": "assets",
          "field": "status",
          "formula": "IF(disposed, 'Disposed', 'In use')",
          "when": "ISBLANK(status)"
        },
        { "op": "removeField", "table": "assets", "field": "disposed" }
      ]
    }
  ],
  "tests": [
    {
      "name": "v1 file upgrades",
      "schemaVersion": 1,
      "tables": {
        "assets": [
          {
            "id": "a1",
            "tag": "IT-001",
            "name": "Laptop",
            "category": "IT",
            "location": "Leeds",
            "purchased": "2023-04-01",
            "value": 1200,
            "lifeYears": 3,
            "disposed": false
          },
          {
            "id": "a2",
            "tag": "IT-002",
            "name": "Laptop",
            "category": "IT",
            "location": "leeds ",
            "purchased": "2020-01-10",
            "value": 1100,
            "disposed": true
          },
          {
            "id": "a3",
            "tag": "FN-001",
            "name": "Desk",
            "category": "Furniture",
            "location": "",
            "value": 300
          }
        ]
      },
      "expect": [
        { "table": "sites", "rows": 2 },
        { "formula": "COUNT(assets WHERE ISBLANK(site)) = 0" },
        { "table": "assets", "row": "a1", "field": "cost", "equals": 1200 },
        { "table": "assets", "row": "a2", "field": "status", "equals": "Disposed" },
        { "table": "assets", "row": "a3", "field": "status", "equals": "In use" }
      ]
    },
    {
      "name": "empty v2 file",
      "schemaVersion": 2,
      "tables": { "assets": [], "sites": [] },
      "expect": [{ "table": "sites", "rows": 0 }]
    },
    {
      "name": "Book value and replacement date",
      "settings": { "asAt": "2022-01-01", "defaultLife": 5 },
      "tables": {
        "sites": [{ "id": "s1", "name": "Leeds" }],
        "assets": [
          {
            "id": "a1",
            "tag": "IT-001",
            "name": "Server",
            "site": "s1",
            "purchased": "2020-01-01",
            "cost": 1000,
            "lifeYears": 4,
            "status": "In use"
          },
          {
            "id": "a2",
            "tag": "FN-001",
            "name": "Desk",
            "site": "s1",
            "purchased": "2015-06-01",
            "cost": 300,
            "status": "In use"
          }
        ]
      },
      "expect": [
        { "table": "assets", "row": "a1", "field": "bookValue", "equals": 499.66 },
        { "table": "assets", "row": "a1", "field": "replaceBy", "equals": "2024-01-01" },
        { "table": "assets", "row": "a2", "field": "bookValue", "equals": 0 },
        { "table": "assets", "row": "a2", "field": "endOfLife", "equals": "Past end of life" },
        { "table": "sites", "row": "s1", "field": "bookValue", "equals": 499.66 }
      ]
    }
  ]
}
```
