> This page is published from `docs/NOTEBOOK_FILE_FORMAT.md` in the Tycho
> repository. Paths such as `src/…` and `docs/…` refer to files in that
> repository. The JSON schemas are at
> https://graph-paper.io/docs/schema/export-envelope.schema.json and
> https://graph-paper.io/docs/schema/notebook-content.schema.json.

# Notebook File Format

A reference for the JSON file format used by Graph Paper notebook documents.
This document is written so that a third party can **read** a notebook file,
**create** a valid one from scratch, or **build a converter** (e.g. a tool that
turns a PDF or a Markdown paper into a notebook).

> Audience: external developers. You do **not** need the Graph Paper source to
> use this. Everything below is derivable from a plain JSON reader/writer plus a
> SHA-256 implementation.

---

## 1. The big picture

A notebook is distributed as a **`.json` export file**. That file is a small
versioned envelope wrapping one or more **documents**. Each document carries a
`type` and a `content` payload. When `type` is `"notebook"`, the `content` is a
**notebook document** — a *section tree* with page geometry, an appearance
preset, and ordered content items (prose, equations, compute blocks, tables,
figures).

```
ExportFile                       ← the .json file you write/read
└── documents[]
    └── ExportedDocument
        ├── title, type, createdAt, contentHash
        └── content              ← NotebookDocument (when type === "notebook")
            ├── version
            ├── page             ← page geometry
            ├── preset           ← appearance preset
            └── sections[]
                └── Section
                    ├── layout   ← columns, gutters
                    └── items[]  ← TextBlock | EquationBlock | ComputeBlock | TableBlock | FigureBlock
```

Two important properties of the loader make authoring forgiving:

- **The decoder is total** — it never throws on author content. Missing fields
  are filled with defaults, out-of-range numbers are clamped, unknown enum
  values fall back, and content it cannot interpret is *preserved* (wrapped as an
  `unsupported` item) rather than dropped. This means a **minimal** document is
  valid, and you can omit anything you don't need.
- **Forward content loads read-only.** A `content.version` greater than the one
  documented here, or content whose shape disagrees with `type`, is loaded
  *opaque* (read-only, bytes preserved). Producers should always emit
  `version: 1`.

### Where this format is used in the app

This is the **same archive format** users see in the app — there is no separate
"developer" format. Three entry points read or write this exact envelope, so a
file you hand-author works with all of them:

| Entry point | Direction | Scope |
| --- | --- | --- |
| **Settings → Account → Export Data** | write | The user's **entire owned corpus** — *all* documents (active + archived, excluding trashed), of **every** `type` (`notebook` **and** `plot`), as many `documents[]` entries in one file |
| **Settings → Account → Import Data** | read | Imports every entry of any type; **deduplicates by `contentHash`** and is **bounded by the account storage quota** |
| **Single-document export** (a notebook's own export menu) | write | The same envelope with a **single** entry |
| **Single-notebook import** (from the notebook surface) | read | The same envelope, but takes only the **first `type: "notebook"`** entry; no dedup (always creates) |

Practical consequences for a converter:

- A file that conforms to this spec imports cleanly through **Settings → Import
  Data**. Because that path **dedupes by `contentHash`**, get the hash right
  (§4) or a re-import may be silently skipped as a duplicate.
- The Settings bulk export mixes `notebook` and `plot` entries. This spec
  documents the `content` of a **notebook** entry; a `plot` entry rides in the
  same envelope with a different `content` shape (a flat `cells[]` array — not
  covered here). Filter on `type` if you only care about notebooks.
- Import is subject to the account's **document-count quota**; entries beyond the
  limit are skipped with a warning.

---

## 2. The export file envelope

This is the top-level shape of the `.json` file.

```jsonc
{
  "formatVersion": 1,                 // MUST be the integer 1
  "exportedAt": "2026-06-27T12:00:00.000Z", // ISO-8601 timestamp (informational)
  "documents": [ /* one or more ExportedDocument */ ]
}
```

Each entry in `documents`:

```jsonc
{
  "title": "My Notebook",             // string, required
  "type": "notebook",                 // string, required. Use "notebook" for a notebook.
  "contentHash": "a1b2c3…",           // SHA-256 hex of content (see §4), required
  "createdAt": "2026-06-27T12:00:00.000Z", // ISO-8601, required
  "content": { /* NotebookDocument */ }     // object, required
}
```

### Validation rules an importer enforces

A file is rejected unless **all** of these hold:

| Rule | Failure |
| --- | --- |
| Top level is a non-array object | `invalid` |
| `formatVersion === 1` | `unsupported-format` |
| `documents` is an array | `invalid` |
| `documents` is non-empty | `empty` |
| Each entry has string `title`, string `type`, string `contentHash`, and a non-null object `content` | `invalid` |

Note what is **not** validated at the envelope level: the *internal* shape of
`content` is never rejected — it is normalized by the notebook decoder (§5–§9).
So even a partially-malformed `content` will import; it just gets coerced.

### A file may contain many documents

The same envelope is used for a single-document export and a full-corpus export.
A converter that produces one notebook simply emits a `documents` array of
length 1. Importers may filter by `type` (e.g. "import the first `notebook`
entry").

---

## 3. Document `type` values

- `"notebook"` — the section-tree document this spec describes.
- `"plot"` — a Graph Paper *graph* document (a different, flat `cells[]` shape,
  not covered here).

The `type` on the export entry is authoritative. **Do not** put plot-shaped
content under `type: "notebook"`; the loader detects a top-level `cells` array
inside a notebook's `content` and refuses to normalize it (loads opaque), to
avoid silently discarding data.

---

## 4. The content hash

`contentHash` is the lowercase hex **SHA-256** of a *stable* JSON serialization
of the `content` object. "Stable" means: identical to `JSON.stringify`, except
**object keys are sorted ascending at every level** (arrays keep their order).

Pseudocode:

```
stableStringify(value):
  JSON.stringify(value, replacer) where every plain object has its keys
  emitted in sorted order (recursively). Arrays and scalars are untouched.

contentHash = hex( SHA-256( UTF-8 bytes of stableStringify(content) ) )
```

Reference TypeScript (the exact algorithm Graph Paper uses):

```ts
function stableStringify(value: unknown): string {
  return JSON.stringify(value, (_, v) => {
    if (typeof v === "object" && v !== null && !Array.isArray(v)) {
      const out: Record<string, unknown> = {};
      for (const k of Object.keys(v).sort()) out[k] = v[k];
      return out;
    }
    return v;
  });
}

async function contentHash(content: unknown): Promise<string> {
  const bytes = new TextEncoder().encode(stableStringify(content));
  const digest = await crypto.subtle.digest("SHA-256", bytes);
  return [...new Uint8Array(digest)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}
```

What the hash is used for: it is the importer's **deduplication key** — two
documents with the same `contentHash` are treated as the same document. The
server recomputes its own hash on save, so the value you supply must be present
and well-formed, but the platform does not trust it as authoritative content
integrity. If you cannot compute SHA-256, you still must supply a string;
supplying a wrong hash only defeats dedup (a re-import may create a duplicate).

---

## 5. The notebook document (`content`)

```jsonc
{
  "version": 1,            // integer, MUST be 1 for the format in this doc
  "minorVersion": 0,       // optional integer ≥ 0; additive, never gates load (§16)
  "deck": false,           // optional boolean, default false; sibling of `page` (§16)
  "page": { /* PageGeometry */ },
  "preset": { /* PresetRef */ },
  "sections": [ /* one or more Section */ ],
  "metadata": { /* extension bag (§16) */ },
  "assets": { /* asset table (§16) */ }
}
```

| Field | Type | Required | Default if omitted |
| --- | --- | --- | --- |
| `version` | `1` | recommended | treated as `1`; a value `> 1` makes the doc load read-only |
| `minorVersion` | integer ≥ 0 | no | `0`. The **minor** version (§16 ladder — the current shipped surface is **minor 2**); producers stamp the current minor on seed/save, the translation stays faithful. Only a higher **major** `version` loads read-only. Preserved |
| `deck` | boolean | no | `false`. Document-level deck flag (§16 ladder, minor 2; `roadmap/NOTEBOOK_DECKS.md` §3), sibling of `page`. Only literal `true` is kept — a non-`true` value decodes as omitted. Preserved |
| `page` | object | no | infinite portrait page, 96 px margins |
| `preset` | object | no | `{ "base": "basel" }` |
| `sections` | array | no | one empty section is seeded |
| `metadata` | object | no | none. A namespaced, size-capped extension bag (§16) |
| `assets` | object | no | none. A document asset table (§16), keyed by content sha256 |

A checked-in **JSON Schema** mirrors this contract:
`docs/schema/notebook-content.schema.json` (the `content`) and
`docs/schema/export-envelope.schema.json` (the §2 envelope). It is *descriptive* —
the decoder stays authoritative — but a producer that validates against it
round-trips cleanly.

The smallest document the loader will accept is literally `{}` — it seeds a
page, a preset, and one empty section. But a useful producer should emit at least
a page, a preset, and the sections it wants.

---

## 6. Page geometry (`page`)

Describes the page size, orientation, and margins. All lengths are **CSS pixels
at 96 dpi** (so 1 inch = 96 px; A4/Letter use their physical size converted to
px).

```jsonc
// Named finite formats, or the "infinite" single scrolling page:
{ "format": "infinite", "orientation": "portrait", "margins": { … } }
{ "format": "a4",      "orientation": "portrait", "margins": { … } }
{ "format": "letter",  "orientation": "portrait", "margins": { … } }
{ "format": "legal",   "orientation": "portrait", "margins": { … } }
{ "format": "16:9",    "orientation": "landscape", "margins": { … } } // slide
{ "format": "4:3",     "orientation": "landscape", "margins": { … } } // slide
// Custom explicit size:
{ "format": "custom", "orientation": "portrait", "width": 800, "height": 1200, "margins": { … } }
```

| Field | Type | Notes |
| --- | --- | --- |
| `format` | enum | `"infinite" \| "a4" \| "letter" \| "legal" \| "16:9" \| "4:3" \| "custom"` |
| `orientation` | enum | `"portrait" \| "landscape"`. The document **default** orientation — a section may override it (see §8). Default: slides (`16:9`/`4:3`) → landscape; everything else → portrait |
| `margins` | object | `{ top, right, bottom, left }`, each a number ≥ 0 px. Default each side: `96` |
| `width`, `height` | number | **only** for `format: "custom"`; px, each ≥ 32. If a custom page lacks valid dimensions it coerces to `"infinite"` |

**`infinite`** is a single, vertically-growing page (no pagination). The named
finite formats paginate. Unknown `format` values coerce to `"infinite"`.

Default page (used when `page` is omitted):

```json
{ "format": "infinite", "orientation": "portrait",
  "margins": { "top": 96, "right": 96, "bottom": 96, "left": 96 } }
```

---

## 7. Appearance preset (`preset`)

A self-contained reference to a built-in visual identity (page, prose, math, and
plot styling), plus optional inline overrides that travel with the document so
any viewer renders it identically.

```jsonc
{
  "base": "basel",                    // required-ish; one of the built-ins below
  "fonts": {                          // optional font-role overrides
    "text": "Libertinus Serif",
    "math": "Libertinus Math",
    "heading": "…", "caption": "…", "plotLabel": "…"
  },
  "palette": "vivid",                 // optional: a named palette OR an array of CSS colors
  "mathScale": 1.05,                  // optional: scales math relative to body text
  "label": "My House Style"           // optional: display-only name, shown to viewers
}
```

| Field | Type | Notes |
| --- | --- | --- |
| `base` | enum | `"basel" \| "vellum" \| "academic" \| "slides"`. **Always a built-in** — never a private/custom id. Unknown values coerce to `"basel"` |
| `fonts` | object | Per-role font stacks. Roles: `text`, `math`, `heading`, `caption`, `plotLabel`. Any subset; non-string values dropped. The decoder accepts **any** non-empty string here (no registry check at load time), but only a *bundled* face (below) actually renders as chosen — an unbundled name falls back to the browser's default for that generic family |
| `palette` | string \| string[] | A named color preset — one of `"light" \| "dark" \| "mono" \| "vivid" \| "pastel" \| "robust"` (`ColorPresetName`, `src/config/types/config-types.ts`) — or an explicit ordered list of CSS color strings. An unrecognized name is dropped at decode/sanitize (the base preset's own palette applies); it never crashes |
| `mathScale` | number | Multiplier applied to the math style's scale |
| `styles` | object | **Data-reserved** custom-style overrides, keyed by style id (SS-F9). No v1 authoring UI writes these; the decoder, both Yjs mirrors, and the worker carry them **opaquely** (verbatim JSON, size-sanity only: 16 KB per bag, UTF-8 of the JSON serialization — an over-cap or non-serializable bag is dropped deterministically; cap constant `PRESET_BAG_MAX_BYTES`, `src/graph-paper/presets/types.ts`, mirrored in the worker) so a future Styles-panel edit round-trips losslessly. Non-object → dropped |
| `colors` | object | **Data-reserved** per-token color overrides (SS-F9). Carried opaquely, like `styles` (same 16 KB per-bag cap) |
| `label` | string | Cosmetic name carried inline; not used for resolution |

**Bundled font faces** (`src/graph-paper/chrome/font-registry.ts`) — the only
faces Graph Paper ships glyph data for; any other `fonts` value still decodes
but is not guaranteed to render as requested:

- **Math** (`fonts.math`): `NewComputerModernMath`, `Fira Math`,
  `IBM Plex Math`, `TeX Gyre Pagella Math`, `Libertinus Math`.
- **Text** (`fonts.text` / `heading` / `caption` / `plotLabel`):
  `Computer Modern`, `STIX Two Text`, `ETBook`, `Libertinus Serif`,
  `IBM Plex Serif`, `Firava` (alias `Fira`), `IBM Plex Sans`, `Lato`.

Default preset (used when `preset` is omitted): `{ "base": "basel" }`.

For a converter, `{ "base": "basel" }` (or `"academic"` for a paper-like look)
is a perfectly good choice; everything else is optional polish.

---

## 8. Sections (`sections`)

A document is an ordered list of one or more sections. Each section has a layout
(columns) and an ordered list of content items.

```jsonc
{
  "id": "sec-1f3c…",        // stable, document-unique string (see §13)
  "name": "Introduction",   // optional author label (shown in ToC / inspector)
  "breakBefore": true,      // optional; start this section on a new page (paginated formats only)
  "orientation": "landscape", // optional; overrides the document default orientation (§6)
  "scope": "inherit",       // optional; "own" (default) | "inherit" — merge this section into the preceding compute scope
  "layout": {
    "columns": 1,           // integer ≥ 1, equal-width columns. Default 1
    "columnGutter": 24,     // px ≥ 0 gap between columns. Default 24
    "rowGap": 12,           // optional px ≥ 0 vertical gap between items
    "columnFill": "balance" // optional: "sequential" | "balance"
  },
  "items": [ /* NotebookBlock… */ ]   // ordered; may be empty
}
```

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | recommended | If missing/blank, one is generated. Duplicates are reassigned |
| `name` | string | no | Author label |
| `breakBefore` | boolean | no | Only meaningful on paginated page formats |
| `orientation` | enum | no | `"portrait" \| "landscape"`. Overrides the document default `page.orientation` (§6). Absent ⇒ inherit. A section whose resolved orientation differs from the page it would continue on **starts a fresh page** (a sheet has one orientation), so adjacent same-orientation sections still share pages but an orientation change forces a page break. Only meaningful for the named finite formats — inert (stored but ignored) for `custom` (explicit dims) and `infinite` (no pages) |
| `scope` | enum | no | `"own"` (default) or `"inherit"` — see **Compute scope** below. `"own"` — the section is its own compute scope. `"inherit"` — continue the **preceding** section's compute scope, so a purely presentational split (a column change, a page break, an orientation override) no longer walls off definitions; consecutive `"inherit"` sections merge into ONE namespace (a single scope both ways, not one-way visibility). A leading `"inherit"` (nothing precedes) behaves as `"own"`. Open value — only `"inherit"` is stored; an unknown value coerces to `"own"`. No author UI in v1 (D5 defers exposure) |
| `layout.columns` | integer | no | ≥ 1; floored & clamped. Default 1 |
| `layout.columnGutter` | number | no | ≥ 0 px. Default 24 |
| `layout.rowGap` | number | no | ≥ 0 px |
| `layout.columnFill` | enum | no | `"sequential"` or `"balance"`. Default: sequential (finite pages) / balance (infinite) |
| `items` | array | no | Non-array → empty list |

If `sections` is missing or empty, one empty section is seeded.

**Compute scope (`scope`).** By default a section is its own compute scope
(`src/graph-paper/notebook/README.md` §5.3), so a purely presentational section
split (a column change, a page break, an orientation override) walls off
definitions from the sections around it. Setting `scope: "inherit"` continues the
**preceding** section's scope instead of starting a new one: consecutive sections
joined by `"inherit"` share ONE compute namespace (definitions cross the split in
both directions — it is a single scope, not one-way visibility), and chains merge
(`own → inherit → inherit` = one scope spanning three sections). A leading
`"inherit"` (nothing precedes) behaves as `"own"`. Shipped in the decoder, both
Yjs mirrors, the compute graph, and export (plan item 3.1; D5,
`requirements/todo/NOTEBOOK_EXPORT.md`, 2026-07-01). Author UI is
deferred (D5) — the field exists so layout-only splits and slide decks can share
a scope regardless of how (or whether) it later surfaces to authors.

---

## 9. Content items (`items`)

Every item shares a common envelope, then adds kind-specific fields. The `kind`
field is the discriminator.

### 9.1 Shared item envelope

```jsonc
{
  "id": "item-…",            // stable, document-unique string
  "kind": "text",            // "text" | "equation" | "compute" | "table" | "graphics" | "figure" | "unsupported"
  "breakBefore": true,       // optional; start on a new page (paginated)
  "columnBreakBefore": true, // optional; start in a new column (multi-column sections only)
  "columnSpan": 2            // optional; integer ≥ 1, or the string "full"
}
```

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | Generated if missing; duplicates reassigned |
| `kind` | enum | Required. Unknown kinds become `"unsupported"` (preserved verbatim) |
| `breakBefore` | boolean | Page break before this item (paginated formats) |
| `columnBreakBefore` | boolean | Column break; honored only when the section has `columns > 1`. A page break wins over a column break |
| `columnSpan` | number \| `"full"` | Clamped to `[1, columns]`; ignored when `columns === 1` |
| `metadata` | object | Optional extension bag (§16); namespaced, size-capped |

### 9.2 Text block — `kind: "text"`

Prose. A text block holds an ordered list of **paragraphs**, each a single
block-level unit — a paragraph, heading, caption, rule, quote paragraph, list
item, or code block (see the role table below). The block *level* lives in
`role`; the text is **inline Markdown** in `source` (no heading prefix in the
text — see §10).

```jsonc
{
  "id": "item-…",
  "kind": "text",
  "paragraphs": [
    { "id": "blk-…", "role": "heading-1", "source": "Introduction" },
    { "id": "blk-…", "role": "body", "source": "Einstein showed that $E = mc^2$." },
    { "id": "blk-…", "role": "caption",   "source": "Figure 1. Mass–energy." }
  ]
}
```

A paragraph:

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | Generated if missing |
| `role` | string | The block's role — see the table below. Stored as an **open string** (§16) — an unknown/forward role is **preserved** and *rendered* as a paragraph, never rewritten |
| `styleId` | string | optional named presentation style (slug `^[a-z][a-z0-9-]*$`); absent → the role's default style. Unknown ids fall back at render, so they round-trip safely |
| `source` | string | **Inline** Markdown body, **without** any block-role prefix (no leading `#`). Non-string → `""` |
| `list` | object | Only meaningful when `role === "list-item"` — `{ marker, ordinal?, indent }` (see below). Absent on every other role |
| `language` | string | Only meaningful when `role === "code-block"` — the fence's info string (e.g. `"python"`); `""` = no language. Absent on every other role |

Known render roles:

| Role | Meaning |
| --- | --- |
| `paragraph` | Body text (the default) |
| `heading-1` … `heading-6` | Section headings |
| `caption` | A caption paragraph (e.g. under a table or figure) |
| `hr` | A thematic break — a **void block**: no editable text. `source` SHOULD be `""` (the app's editor always writes `""`; decode does not force it, and the renderer ignores any content) |
| `blockquote` | One paragraph of a block quote; a multi-paragraph quote is consecutive `blockquote` blocks |
| `list-item` | One item of a bulleted or numbered list; carries `list` (below); a list is consecutive `list-item` blocks |
| `code-block` | A fenced code block; `source` is **verbatim** code — not parsed as Markdown/math — and carries `language` |

`list` (present only when `role === "list-item"`):

```jsonc
{ "marker": "-", "indent": 0 }                    // an unordered (bullet) item
{ "marker": ".", "ordinal": 1, "indent": 0 }       // an ordered item, "1."
```

| Field | Type | Notes |
| --- | --- | --- |
| `marker` | string | Bullet char (`-`, `+`, `*`) or ordered delimiter (`.`, `)`). Ordered ⟺ `ordinal` is set |
| `ordinal` | number | The item's number, for an ordered list. Absent ⇒ a bullet item |
| `indent` | integer | Nesting depth, 0-based |

A text block must have ≥ 1 paragraph; an empty/missing `paragraphs` array seeds one
empty paragraph.

> **Important for converters:** the notebook prose model is **block roles +
> inline markup**, not a nested block tree — a multi-line construct (a quote, a
> list, a fence) is a RUN of consecutive same-purpose blocks, not one block with
> children. Represent:
> - paragraphs/headings/captions/quotes/list items/code blocks/rules → one
>   paragraph each, with the matching `role`;
> - tables → a `table` item (§9.5);
> - display equations → an `equation` item (§9.3);
> - images → a `figure` item (§9.7);
> - everything else (bold, italics, inline code, links, inline math) → inline
>   Markdown inside `source` (§10).

### 9.3 Equation block — `kind: "equation"`

A static, numbered display equation. **Never evaluated** — it is pure LaTeX
display math (like a LaTeX `equation` environment). This is the right item for
typesetting a formula from a paper.

```jsonc
{
  "id": "item-…",
  "kind": "equation",
  "latex": "E = mc^2",
  "numbered": true,        // optional; default true. false → unnumbered (like equation*)
  "align": "center",       // optional; "left" | "center" | "right". Default "center"
  "tag": "$\\ast$"         // optional; custom amsmath \tag fragment. Omitted when unset
}
```

| Field | Type | Notes |
| --- | --- | --- |
| `latex` | string | LaTeX display math. Non-string → `""`. Never contains a root-level `\tag` (hoisted to `tag` at every ingress) |
| `numbered` | boolean | Default `true`. The running `(N)` number is **derived** from document order — never store the number |
| `align` | enum | Default `"center"` |
| `tag` | string | Custom equation tag (amsmath `\tag`): a LaTeX fragment (text mode with `$...$` inline math). Present ⇒ the gutter shows `(tag)` instead of a running number and the block consumes **no** number (precedence over `numbered`). Empty `""` is dropped on decode (minor 7) |

### 9.4 Compute block — `kind: "compute"`

A **live** computation: an input expression that the Compute Engine evaluates,
with the result rendered next to/below it. Use this only when you want the value
computed by Graph Paper; for static math from a document, prefer an `equation`
block (§9.3).

```jsonc
{
  "id": "item-…",
  "kind": "compute",
  "inputType": "math",       // optional; default "math" (the built-in math-field input type)
  "inputEncoding": "plain-text", // optional; open string, default "plain-text" (known: "structured" | "json")
  "evalOptions": { "approximate": true }, // optional; input-type-specific eval flags
  "input": "\\int_0^1 x^2 dx",   // the input payload (LaTeX for the math input type)
  "output": {
    "as": "auto",                // renderer selector; "auto" | "none" | "math" | "plot" | "table" | <plugin id>
    "placement": "below",        // "below" | "above" | "left" | "right" | "input-only" | "output-only"
    "optionsByRenderer": {       // optional; per-renderer view options, keyed by renderer id
      "plot": { /* plot view options */ }
    }
  }
}
```

| Field | Type | Notes |
| --- | --- | --- |
| `inputType` | string | Registered input type. Default `"math"` (built-in CE math-field input type) |
| `inputEncoding` | string | **Open string** (CP rec 13): known values `"plain-text" \| "structured" \| "json"`, default `"plain-text"`. An unknown/forward value is preserved verbatim on the wire and coerced at use, never rewritten on save (like `output.as` and prose `role`) |
| `evalOptions` | object | Flat key→value eval options for the input type (e.g. `approximate`). Changing one re-evaluates |
| `input` | string | The block payload. For the math input type it is **LaTeX**. Non-string → `""` |
| `output.as` | string | Renderer view. `"auto"` picks the default for the produced value. Any non-empty value is preserved verbatim (forward-compatible with plugin renderers) |
| `output.placement` | enum | Where output sits relative to input. Default `"below"`. Unknown → `"below"` |
| `output.optionsByRenderer` | object | Sparse map of renderer-id → opaque options bag (e.g. `plot` title/legend/aspect). Preserved as-is |

The output is the only visibility control (`input-only` / `output-only` hide one
side). The result value itself is **not** stored — it is recomputed on load.

### 9.5 Table block — `kind: "table"`

An authored data table (optionally referenceable by compute blocks via `name`).

```jsonc
{
  "id": "item-…",
  "kind": "table",
  "name": "data",            // optional; section-scoped symbol (referenceable by compute blocks)
  "table": {
    "columns": [
      { "header": "x", "type": "number" },
      { "header": "label", "type": "string" }
    ],
    "cells": [               // row-major; each row is an array of strings
      ["1", "one"],
      ["2", "two"]
    ],
    "contentMode": "text"    // optional; "text" (default) | "latex" | "markdown"
  }
}
```

| Field | Type | Notes |
| --- | --- | --- |
| `name` | string | Optional symbol name. Duplicate names within a section are dropped (first wins) |
| `table.columns[].header` | string | Column header text. Non-string → `""` |
| `table.columns[].type` | enum | `"number" \| "string"`. Unknown → `"string"` |
| `table.cells` | string[][] | Row-major. Every cell is a **string**; non-strings are stringified. Ragged rows are padded/truncated to the column count |
| `table.contentMode` | enum | `"text"` (plain data, default) / `"latex"` (cells are math) / `"markdown"` (cells are inline Markdown) |

If `table` is missing/not an object, the item is preserved as an `unsupported`
block instead.

### 9.6 Unsupported block — `kind: "unsupported"`

The loader's escape hatch: any item it can't interpret (unknown `kind`, or a
payload that failed to decode) is wrapped here so its bytes survive a round-trip.
You normally won't *produce* these, but you should **preserve** them if you read
and re-write a file.

```jsonc
{
  "id": "item-…",
  "kind": "unsupported",
  "originalKind": "diagram",   // optional; the kind that failed to decode
  "raw": { /* the verbatim original payload, preserved losslessly */ }
}
```

### 9.7 Figure block — `kind: "figure"`

An embedded image. Markdown cannot carry a bound caption, display sizing, or
block alignment for an image, so a figure is an **item**, not a prose role
(§16's three-tier placement rule). The image *bytes* are never inline — the
item carries only a content-addressed reference into the document's asset
table (§16); the actual URL is derived at load time from `(doc id, asset)`
and is never persisted.

```jsonc
{
  "id": "item-…",
  "kind": "figure",
  "asset": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "alt": "A plot of the residuals against fitted values.", // optional; default ""
  "caption": "Figure 1. Residual plot.",                   // optional; inline Markdown (§10)
  "width": 0.6,                                             // optional; (0, 1]. Default 1 (full width)
  "align": "left"                                           // optional; "left" | "center" | "right". Default "center"
}
```

| Field | Type | Notes |
| --- | --- | --- |
| `asset` | string | Required. Lowercase-hex SHA-256 (`^[0-9a-f]{64}$`) of the image bytes — also the key into `content.assets` (§16). An item whose `asset` is missing or not a well-formed sha is preserved as `unsupported` instead of a broken figure |
| `alt` | string | Optional accessibility text. Default `""` |
| `caption` | string | Optional inline-Markdown caption (§10), rendered as a `<figcaption>`; collaboratively edited as its own text stream in the app. Omit for no caption |
| `width` | number | Optional, `(0, 1]` — a fraction of the content column. `1` is the default (full width) and is normalized away on decode: only a value strictly inside `(0, 1)` is stored; `1` (or anything ≥ 1) decodes to *absent*, and anything `≤ 0` is also dropped |
| `align` | enum | `"left" \| "center" \| "right"`. Default `"center"`. Only visible when `width < 1` |

**Missing-asset placeholder.** A well-formed `asset` sha with no matching entry
in `content.assets` — e.g. the bytes were never uploaded, or an importer read
the JSON without ingesting the referenced bytes — is not a load error: the
figure renders a placeholder rather than being dropped, consistent with the
decoder's general forgiveness contract (§12).

### 9.8 Graphics block — `kind: "graphics"`

An authored vector drawing (shapes, paths, connectors, math labels — the Graph
Paper graphics editor's native model). Like a `figure`, it carries presentation
and export semantics Markdown cannot express, so it is an **item**, not a prose
role (§16's three-tier placement rule). Unlike a figure it embeds **no external
bytes**: the whole drawing is a **structured object** stored inline under
`graphics`.

```jsonc
{
  "id": "item-…",
  "kind": "graphics",
  "name": "Free-body diagram",  // optional user label; accessible title on export
  "graphics": {                  // GraphicsDocument — a structured object, NOT a string
    "version": 1,
    "width": 240,
    "height": 160,
    "nodes": [ /* rect | ellipse | line | poly | path | group | label | connector */ ]
  }
}
```

| Field | Type | Notes |
| --- | --- | --- |
| `name` | string | Optional user label. **Trimmed; dropped when empty.** It is an accessible **title** for export (below) — **not** a section-scoped compute symbol like a `table` `name`: it is never referenced by compute blocks, so it is **not** section-deduped (a duplicate is kept, not dropped) |
| `graphics` | object | The drawing itself — the graphics editor primitive's **`GraphicsDocument` JSON, verbatim** (a structured object, never a JSON string). Its full schema is **not** duplicated here; the source of truth is `requirements/archive/GRAPHICS_EDITOR.md` §4 (model) / §8.1 (persistence), the way §15 defers other kinds to their specs. The decoder runs that spec's §4.1a validate/normalize pipeline over it on load |

**Decoder behavior.** Two distinct failure modes, both non-fatal (§12):

- **Missing or non-object `graphics`** → the item decodes as an `unsupported`
  block (`originalKind: "graphics"`, revivable — the same precedent as a `table`
  item with no `table` object, §9.5).
- **An object `graphics` that fails the validate/normalize pipeline** is
  preserved **raw and verbatim** — the payload round-trips **byte-for-byte**
  through open→save (the GRAPHICS_EDITOR §8.2 invalid-payload contract; the
  static type is knowingly wrong in this state). The editor surface shows an
  error placeholder (with a "Reset drawing" action), and export emits a
  placeholder block rather than the drawing.

**Export.** A valid graphics block exports as **inline SVG** where the target
supports it (Markdown, HTML); other targets emit a warned placeholder (see
`requirements/todo/NOTEBOOK_EXPORT.md` §4/D7). `name` becomes the exported SVG's
accessible `<title>`.

---

## 10. Inline Markdown in prose `source`

Prose-block `source` is **inline** Markdown only (no block constructs — the
block role is carried by `role`). Supported inline syntax:

| Syntax | Renders as |
| --- | --- |
| `**bold**` or `__bold__` | bold |
| `*italic*` or `_italic_` | italic |
| `***both***` or `___both___` | bold + italic |
| `` `code` `` (also `` `` `…` `` `` for literals containing backticks) | inline code |
| `~~strike~~` | strikethrough |
| `==highlight==` | highlight |
| `$ … $` | inline math (LaTeX), rendered as a `<math-span>` |
| `[text](url)` | link (link text may itself contain inline math) |
| `![alt](url)` | image |
| `<https://example.com>` or `<user@example.com>` | autolink (an absolute-URI or email address wrapped in `<…>`) |
| `&amp;`, `&#8734;`, `&#x221E;` | HTML entity / decimal / hex character reference |
| two or more trailing spaces, or `\` at the end of a line | hard line break |
| `[text][label]`, `[text][]`, `[text]` | reference-style link — recognized syntax, but resolves only against a reference-definition map the caller supplies. Notebook `source` is a single inline unit with no block-level definition construct, so today these always render as literal unresolved text (the same behavior CommonMark specifies for an undefined reference) |

Plain text needs no escaping beyond the usual Markdown rules. Use a `heading-N`
role for headings — **do not** put `#` in `source`.

This same inline subset is shared by two other carriers, parsed by the same
engine: a `figure` item's caption (§9.7) and a `table` item's cells when
`contentMode: "markdown"` (§9.5).

### Reserved `asset:<sha256>` image URL scheme

The `asset:` URL scheme is **reserved** for a future inline-image feature that
would resolve an `![alt](asset:<sha256>)` reference against the document's
`content.assets` table (§16 → Assets) — the same content-address space a
`figure` item uses. It is **not implemented** in v1:

- Producers **must not** use `asset:` URLs for any other purpose.
- A reader that does not support the scheme renders the image's **alt text**
  (the fallback CommonMark specifies for an image it cannot load).

Reserving the scheme now keeps the design space free of a collision with a
real future remote `![alt](https://…)` image (which already renders on the
read-only path). Inline asset-images themselves are **deferred** — document
images today are the `figure` item (§9.7); see `NOTEBOOK_FORMAT_FUTURE.md` →
_Remaining deferred limitations_ for the revive trigger.

---

## 11. IDs

Sections, items, and paragraphs each carry a stable, **document-unique** `id`
string. They anchor reordering, selection, collaboration, and undo.

- Any non-empty string is accepted. If you omit an id (or leave it blank), one is
  generated on load.
- Graph Paper mints ids as `"<prefix>-<uuid>"` with prefixes `sec` (section),
  `item` (item), `blk` (paragraph) — e.g. `"item-2f1c8e3a-…"`. You may follow
  this convention or use your own scheme; only **uniqueness within the document**
  matters.
- **Duplicate ids are reassigned on load** (first occurrence kept). So a sloppy
  converter won't corrupt a document, but you should still emit unique ids to
  keep `contentHash` stable across round-trips.

---

## 12. Defaults, clamping & forgiveness (decoder behavior)

The notebook decoder is *total* — useful to know so you can omit fields safely:

- **Missing object → defaults.** Missing `page` → infinite portrait; missing
  `preset` → Basel; missing/empty `sections` → one seeded section; missing
  `paragraphs` in a text item → one empty paragraph.
- **Numbers are clamped.** Margins ≥ 0; custom page dims ≥ 32; `columns` ≥ 1
  (floored); `columnGutter`/`rowGap` ≥ 0; `columnSpan` clamped to `[1, columns]`.
- **Unknown enum values fall back.** Unknown page `format` → infinite; unknown
  prose `role` → paragraph; unknown column `type` → string; unknown output
  `placement` → below; unknown `preset.base` → basel.
- **Non-strings become empty strings** for text fields (`source`, `latex`,
  `input`, table cells/headers).
- **Unknown item kinds are preserved** as `unsupported` (never dropped); so is
  a `figure` item whose `asset` isn't a well-formed sha256 (§9.7).
- **Legacy/extra fields are tolerated.** Older shapes are migrated where a
  mapping exists; otherwise extra keys are ignored (not an error).

Read-only (opaque) load happens only for: `content.version > 1`, or a notebook
`content` that contains a top-level `cells` array (plot-shaped — never
normalized, to avoid data loss).

**Pipeline-totality invariant.** Totality is a promise about the whole
document pipeline, not just the decoder: a document the decoder accepts must
**open, render, and export** without crashing and without reinterpreting
preserved content. Unknown open-string values (prose roles, style ids, palette
names, compute-block input types/encodings) coerce at **render** time only —
never at evaluation or export, which must treat them as preserved/inert. Two
known violations are tracked in the deficiencies ledger: the unknown
`preset.palette` crash (fix in progress) and unknown compute-block `inputType`
content being evaluated and exported as math.

---

## 13. Worked example — a complete minimal notebook

A self-contained, importable file with a heading, a paragraph with inline math,
a numbered display equation, and a small data table.

```jsonc
{
  "formatVersion": 1,
  "exportedAt": "2026-06-27T12:00:00.000Z",
  "documents": [
    {
      "title": "On the Electrodynamics of Moving Bodies",
      "type": "notebook",
      "contentHash": "<sha256 hex of the content object below>",
      "createdAt": "2026-06-27T12:00:00.000Z",
      "content": {
        "version": 1,
        "page": {
          "format": "a4",
          "orientation": "portrait",
          "margins": { "top": 96, "right": 96, "bottom": 96, "left": 96 }
        },
        "preset": { "base": "academic" },
        "sections": [
          {
            "id": "sec-intro",
            "name": "Introduction",
            "layout": { "columns": 1, "columnGutter": 24 },
            "items": [
              {
                "id": "item-h1",
                "kind": "text",
                "paragraphs": [
                  { "id": "blk-title", "role": "heading-1", "source": "Mass–energy equivalence" },
                  { "id": "blk-p1", "role": "body",
                    "source": "The energy $E$ of a body at rest with mass $m$ is given by the relation below, where $c$ is the speed of light." }
                ]
              },
              {
                "id": "item-eq1",
                "kind": "equation",
                "latex": "E = mc^2",
                "numbered": true,
                "align": "center"
              },
              {
                "id": "item-tbl1",
                "kind": "table",
                "name": "constants",
                "table": {
                  "columns": [
                    { "header": "symbol", "type": "string" },
                    { "header": "value", "type": "number" }
                  ],
                  "cells": [
                    ["c", "299792458"],
                    ["m", "1"]
                  ],
                  "contentMode": "text"
                }
              }
            ]
          }
        ]
      }
    }
  ]
}
```

Smallest possible `content` that still loads (everything else defaulted):

```json
{ "version": 1, "sections": [
  { "id": "s1", "layout": { "columns": 1, "columnGutter": 24 }, "items": [
    { "id": "i1", "kind": "text", "paragraphs": [
      { "id": "b1", "role": "body", "source": "Hello, notebook." }
    ] }
  ] }
] }
```

---

## 14. Conformance checklist for a converter

To emit a notebook that round-trips cleanly:

1. **Envelope:** `formatVersion: 1`, an `exportedAt`, and a `documents` array of
   length ≥ 1.
2. **Each document:** string `title`, `type: "notebook"`, ISO `createdAt`, and a
   `contentHash` that is the SHA-256 (per §4) of the `content` object.
3. **Content root:** `version: 1`, a `page`, a `preset` (`{ "base": "basel" }` is
   fine), and ≥ 1 `section`.
4. **Map source structure:**
   - headings → paragraphs with `role: "heading-1…6"`;
   - body text → `role: "body"` paragraphs with **inline** Markdown;
   - quotes / list items / code blocks / thematic breaks → paragraphs with
     `role: "blockquote" | "list-item" | "code-block" | "hr"` (§9.2) — a run of
     consecutive same-role blocks, not a nested tree;
   - display equations → `equation` items (LaTeX in `latex`);
   - data tables → `table` items;
   - images → `figure` items (§9.7), referencing an entry in `content.assets`
     (§16);
   - vector drawings → `graphics` items (§9.8), carrying the drawing's
     `GraphicsDocument` object inline;
   - inline emphasis / code / links / images / inline math → inline Markdown
     in `source`.
5. **Give every section, item, and paragraph a unique `id`** (the
   `prefix-uuid` convention is recommended but not required).
6. **Prefer `equation` over `compute`** for static math you don't want
   re-evaluated.
7. **Preserve `unsupported` items verbatim** if you are *editing* an existing
   file rather than generating one.
8. **Emit `version: 1`** — never a higher version, or the document loads
   read-only.

---

## 15. Source of truth

This document mirrors the runtime contract in the Graph Paper source:

- `src/graph-paper/notebook/types.ts` — the type definitions and defaults.
- `src/graph-paper/notebook/decode.ts` — the total, lenient load-path decoder
  (the authority on shape, clamping, and fallback).
- `src/graph-paper/notebook/seed.ts` — the seed/empty document builders.
- `src/graph-paper/document/export-import.ts` — the export-file envelope and
  importer validation.
- `src/config/content-hash.ts` — the stable-stringify + SHA-256 hash.
- `src/graph-paper/presets/types.ts` — the appearance preset (`PresetRef`).

If this document and the code disagree, the code wins; please report the drift.

**Release rule.** Any PR that changes the *observable* shape of `types.ts` or
`decode.ts` (a new/renamed/dropped field, item kind, or enum value; a changed
default; a changed clamp) must update this document AND
`docs/schema/notebook-content.schema.json` in the **same** change. The
enforcement hook is `tests/node/graph-paper/notebook-schema.test.ts`: it
validates a well-formed content object (covering every item kind and current
field) against the checked-in schema, so a shape change that isn't mirrored
into the schema fails that test instead of drifting silently.

---

## 16. Forward-compatibility & extensions

These fields make the format extend safely across versions. All are **additive**:
an older reader preserves what it doesn't understand rather than dropping it.

### Minor versions

`content.minorVersion` (integer ≥ 0, default 0) is the **minor** version;
`version` is the **major**. Only a higher *major* loads read-only (§12). A minor
bump may add only things an older reader can ignore-and-preserve — a new item
`kind`, a new prose `role`, a new value in an open enum, or keys inside a
`metadata` bag.

**Producers stamp the current minor.** A new notebook is seeded at the current
minor, and the client + worker serialize paths stamp it on save when absent (so a
legacy minor-0 doc is upgraded on save while a forward minor is preserved). The
**low-level Yjs translation stays faithful** — it writes `minorVersion` only when
present and omits it when absent — so an untouched older document round-trips at
minor 0. A minor is never a load gate.

**Ladder registry:**

| minor | Shipped surface added |
| --- | --- |
| 0 | Pre-2026-07 base format. |
| 1 | `figure` items (§9.7); the Phase-3 prose roles (`hr`, `blockquote`, `list-item`, `code-block`) with `list`/`language`; prose `styleId`; the cell-plugin fields (`inputType`, `inputEncoding`, `evalOptions`, `output.optionsByRenderer`); item `columnBreakBefore`; `Section.scope`; the compute block `kind` (`"compute"`, renamed from the historical `compute-cell`/`cell`). |
| 2 | The `deck` flag (`roadmap/NOTEBOOK_DECKS.md` §3) — a document-level boolean, sibling of `page`; omitted = `false`. New sibling field on the document (U-R4): an older reader strips it on round-trip, accepted pre-launch (single deployed reader), same as the minor-1 fields. |
| 3 | `TableBlock.caption` (scientific-environments E3) — an inline-Markdown caption on the `table` item kind, collaboratively edited as a `Y.Text` (the `FigureBlock.caption` mirror); the "Table N: …" surface. Omitted when absent; an empty `""` is dropped on decode. New sibling field on the `table` kind (U-R4): an older reader strips it on round-trip, accepted pre-launch (single deployed reader). **Strip verified by inspection** of the pre-E3 readers that never read a `caption` key on a table: `src/graph-paper/notebook/decode.ts` `decodeTableBlock` (copied only `table`+`name`), the `table` read arm in `src/graph-paper/notebook/notebook-ydoc-translation.ts` (read only `table`+`name`), and the worker mirror `case 'table'` read arm in `workers/api/src/lib/notebook-ydoc.ts` — no frozen-decoder fixture (a post-change test cannot exercise a pre-change reader). |
| 4 | The `theorem` + `proof` prose roles (scientific-environments E4) with their `Paragraph.theorem` (`variant`/`label`/`name?`/`counterGroup`/`numbered`/`envGroup`) and `Paragraph.proof` (`envGroup`) metadata — parameterized theorem-like environments + proof runs; derived numbering only (no stored numbers, so no format surface for counters). The open-string `role` values `"theorem"`/`"proof"` are preserved by the §9.2 open-string mechanism; the NEW SIBLING FIELDS `theorem`/`proof` are the U-R4 case: a pre-minor-4 reader strips them on round-trip (the role survives but, without its meta, render-coerces to a body paragraph). **Strip verified by inspection** of the pre-E4 paragraph readers that never read a `theorem`/`proof` key: `src/graph-paper/notebook/decode.ts` `decodeParagraph` (built id/role/styleId/list/language/source/metadata only), the `readParagraph` in `src/graph-paper/notebook/notebook-ydoc-translation.ts` (read id/role/styleId/list/language/source/metadata only), and the worker mirror `readParagraph` in `workers/api/src/lib/notebook-ydoc.ts` — no frozen-decoder fixture (a post-change test cannot exercise a pre-change reader). |
| 5 | `ComputeBlock.resolved` (prompt-cell resolved intent). |
| 6 | REMOVES `ComputeBlock.resolved` — the translation contract (PROMPT_CELL_TRANSLATION_PLAN.md §4) has no persisted resolution state (pre-launch from-scratch mandate: the field ceases to exist; an unknown `resolved` from an older writer is dropped by decode). |
| 7 | `EquationBlock.tag` (equation-tags spec, 2026-07-14) — a custom amsmath `\tag` fragment on the `equation` item kind; present ⇒ the gutter renders `(tag)` and the block consumes no running number (precedence over `numbered`). Omitted when absent; empty `""` dropped on decode. New sibling field on the `equation` kind (U-R4): an older reader strips it on round-trip, accepted pre-launch (single deployed reader). **Strip verified by inspection** of the pre-tag readers that never read a `tag` key on an equation: `src/graph-paper/notebook/decode.ts` `decodeEquationBlock` (copied only `latex`+`numbered`+`align`), the `equation` read arm in `src/graph-paper/notebook/notebook-ydoc-translation.ts`, and the worker mirror `case 'equation'` read arm in `workers/api/src/lib/notebook-ydoc.ts` — no frozen-decoder fixture. |

**Bump process (decided 2026-07-02, umbrella #7).** Any shape-changing PR bumps
`minorVersion` **in the same change** and adds a row to the ladder registry
above describing the added surface — no separate approval step. (This extends
the §15 release rule: a shape-changing PR touches this doc + the JSON schema +
the ladder row together.)

**Sibling-field staging policy (U-R4).** A brand-new *sibling field on an existing
kind* is **not** minor-safe on its own: an older reader that doesn't know the field
copies only the keys it recognizes, so a round-trip through that reader **strips**
the field (unlike a new `kind`/`role`/open-enum value, which the unsupported-wrap +
open-string mechanisms preserve). Before promoting such a field to a first-class
schema key, either stage it under a `metadata["graph-paper"]` bag first (preserved
losslessly by every reader), or document the older-reader strip explicitly as an
accepted loss for that field. Pre-launch, with a single deployed reader, this is an
accepted trade for the minor-1 fields above, the minor-2 `deck` flag, the
minor-3 `TableBlock.caption` field, and the minor-4 `theorem`/`proof` paragraph
meta.

### Metadata bags

`content`, a section, an item, a paragraph, and a compute block `output` may each
carry an optional `metadata` object — a namespaced extension bag. Reserve the
`graph-paper` key for first-party use; third parties should use reverse-DNS
(`com.example.x`). Metadata is preserved losslessly but **size-capped**: a single
bag may not exceed **16 KB** and the per-document total may not exceed **256 KB**
(serialized JSON bytes). Over-cap bags are dropped on load, deterministically (by
scope then id). Asset metadata counts toward the same caps.

### First-party conventions (`metadata["graph-paper"]`)

These are **documented key shapes** inside the first-party
`metadata["graph-paper"]` bag — not schema fields. They ride the metadata bag,
so they add no `kind`/`role`/enum surface, need no minor bump, and are
preserved losslessly by every reader. Each convention's *consumer* (a tag UI,
an export-controls UI, a diagnostics panel) ships with the feature that needs
it; this section reserves the shapes so first-party writers agree on them.
Each convention is defined **at the scope stated below only**: a convention key
written at a different scope (e.g. `tags` at document scope, or `engine` on an
item) is preserved verbatim like any other bag content — the general opaque-bag
rule — but has no defined meaning there and is ignored by first-party
consumers.

**#9 Tags — `item.metadata["graph-paper"].tags?: string[]`.** A set of
free-form labels on an item. Values are **unique** and **order-insignificant**
(a reader must not depend on order); at most **32** tags, each at most **64**
characters.

> **LWW caveat (read before shipping a tag UI).** A `metadata` bag is stored as
> one opaque **last-write-wins** value (see *Metadata bags* above, and the
> metadata-concurrency entry in `NOTEBOOK_FORMAT_FUTURE.md`), so two
> collaborators editing
> tags concurrently clobber each other's whole tag set. Before any
> **concurrently-editable** tag UI ships, tags must move to a namespace-level
> `Y.Map` representation (so unrelated tag edits merge) or the feature must
> explicitly accept LWW.

**#10 Visibility / export controls —
`item.metadata["graph-paper"].presentation?: { sourceVisibleInExport?,
outputVisibleInExport?, collapsed? }`.** Booleans expressing per-item
export/display intent (hide an item's source or output in an export; collapse
it in the editor). An **absent** field means **default-visible** — nothing is
hidden. **Reserved:** a producer may write them and they round-trip, but the
**v1 export emitters do not honor them**; an export-controls consumer lands
later.

**#12 Engine / language declarations.** At **document** scope,
`content.metadata["graph-paper"]` may carry
`engine?: { name: string; version?: string }` plus `defaultInputType?` and
`defaultInputEncoding?` — a producer's record of the compute engine and default
input conventions the document was authored against. **Not stamped on
save:** the app deliberately does **not** write the running Compute Engine
version on every save, because that would churn `contentHash` (the dedup key)
on saves that carry no user edit. Producers and external tools may write it;
the app would write it only at seed time if a consumer ever needs it.

### Item payload caps

A `table` item's `table` payload and a `graphics` item's `graphics` payload
may not exceed **256 KB** each (UTF-8 bytes of the JSON serialization;
constants `ITEM_PAYLOAD_MAX_BYTES` / `ITEM_PAYLOAD_WARN_BYTES` in
`src/graph-paper/notebook/types.ts` — GRAPHICS_EDITOR.md decision 32).
Primary enforcement is the write path: the editing UI warns at **192 KB**
and refuses a commit that would cross the hard cap. On load, an over-cap
payload is **not dropped** — unlike metadata bags, item payloads are primary
user content — it is preserved verbatim and surfaces through the item's
error machinery (graphics: the GRAPHICS_EDITOR §8.2 invalid state with
Reset; table: the revivable `unsupported` placeholder). The worker carries
item payloads opaquely; the 1 MiB whole-document cap is the storage
backstop.

### Assets

`content.assets` is the document's asset table: a map keyed by the **content
address** of the image bytes — lowercase-hex SHA-256, the same string a
`figure` item (§9.7) puts in its `asset` field. There is no separate id space
— the map key IS the sha, which is also why a redundant `sha256` value inside
the entry itself would be pointless (see below).

```jsonc
"assets": {
  "<sha256>": {
    "mime": "image/png",     // required
    "name": "figure.png",    // optional — original filename
    "metadata": { }          // optional — e.g. { width, height } for layout reservation
  }
}
```

| Field | Type | Notes |
| --- | --- | --- |
| `mime` | string | Required, non-empty. The shipped upload pipeline accepts only `image/png`, `image/jpeg`, `image/gif`, `image/webp` (verified by a magic-byte sniff at upload, not the `Content-Type` header alone — `image/svg+xml` is excluded as script-bearing, `image/avif` is deferred; `src/graph-paper/notebook/notebook-assets-types.ts`). **The decoder itself does not enforce this allowlist** — any non-empty string decodes; the restriction is an upload-time gate, not a content-shape rule |
| `name` | string | Optional original filename |
| `metadata` | object | Optional extension bag (the §16 metadata caps apply); the upload pipeline stores `{ width, height }` here so the layout engine can reserve the image's aspect-ratio box before it loads |

Stored content is **sha-only**: a `data` (base64) or `href` field is dropped
by the decoder if present (`decodeAssets`,
`src/graph-paper/notebook/decode.ts`) — there is no inline self-containment
channel, and the JSON Schema rejects `data`/`href` outright so a producer gets
a validation error instead of silent byte loss. A `sha256` field inside the
entry is dropped for the same reason: it would be redundant with the map key,
so don't emit it.

The image **bytes** never live in `content` — they're stored per-document,
outside the document object (`documents/{id}/assets/{sha}` in the app's R2
bucket); `content` only ever carries the sha reference. Caps, enforced at
upload (not decode): **5 MiB** per asset, **50** stored assets per document,
**25 MB** total stored bytes per document
(`src/graph-paper/notebook/notebook-assets-types.ts`).

Self-containment for **export** is a separate envelope, not an inline field: a
`.zip` export carries `index.<ext>` plus the referenced bytes under
`assets/<sha>.<ext>` (shipped — plan item 3.2, `requirements/todo/NOTEBOOK_EXPORT.md`
§12). The native JSON export — including the Settings → Account "Export Data"
full-corpus backup — ships that `.zip` when a document references a figure, so
re-importing it restores the image bytes (import re-hashes each shipped asset and
uploads it into the new document before the content is decoded; a sha mismatch is
skipped). A pure `.json` (no `.zip`) still carries only the sha-only `assets`
table with no bytes — decode stays sha-only either way (the stored-content model
here is unchanged).

Assets are produced first-party by the notebook's Insert-image upload flow:
it computes the sha256 client-side, uploads the bytes, then mints the
`content.assets` entry and a `figure` item (§9.7) referencing it. A
third-party producer may mint `assets` entries directly, but the bytes must
reach the same per-document object storage out of band (there is no
inline-JSON path today) — otherwise the figure renders its placeholder. The
field is preserved verbatim if present, whether or not this reader recognizes
every key in an entry.

### Extension placement — prose role vs block kind vs compute block

Three tiers can host a new content construct; pick by contract (full form with
worked examples: notebook README §4.5):

1. **Prose role** — the construct is Markdown-serializable text plus
   role-scoped metadata (the `list`/`language` pattern), needs no independent
   layout/asset/compute semantics, and participates in prose editing
   continuity (split/merge/caret).
2. **Item kind** — it carries non-payload presentation scalars or asset
   references, needs its own collab scalars, inspector card, and export arm,
   and is **not** author-editable computable source (figure, table, equation).
3. **Compute block** (`compute` + `inputType`) — the payload is
   author-editable source whose value can enter the compute namespace or be
   produced/derived by an evaluator (equation, svg, future script/slider
   cells).

### Open-string prose roles

A paragraph's `role` is stored as an **open string**. The known *render*
roles are `paragraph`, `heading-1`…`heading-6`, `caption`, `hr`, `blockquote`,
`list-item`, `code-block`, `theorem`, `proof` (§9.2); any other value is preserved verbatim and
*rendered as a paragraph* by a reader that doesn't recognize it — never
rewritten in storage. So a future role (e.g. `"callout"`) survives a
round-trip through an older reader.

### Item revival

When the loader meets an item `kind` it doesn't recognize, it wraps it as an
`unsupported` block, preserving the original object under `raw` (§9.6). A *newer*
reader that understands the kind **revives** it back to first-class — so a forward
item kind survives a round-trip through an older reader and renders normally once
opened by a reader that knows it.

**Revival merge is envelope-wins.** On revival, the wrapper's *outer* `id`,
`breakBefore`, `columnBreakBefore`, `columnSpan`, and `metadata` — the fields a
collaborator may have edited while the item sat wrapped — override the
corresponding fields inside the preserved payload; `kind`/`raw`/`originalKind`
are wrapper-only and never carried into the revived item. So an editor that
touches a wrapped item's envelope while it is still opaque does not lose that
edit once a newer reader revives it.

The compute block's wire `kind` was renamed `"cell"` → `"compute"`; by
design there is **no decode alias**, so a pre-rename document's `"cell"`/`"compute-cell"`
items load as preserved `unsupported` (revivable via this same mechanism only by
a reader that maps the kind — which this reader deliberately does not). This is
an accepted pre-launch regression on test documents (there are no real ones).

### IDs are required for producers

Every section, item, and paragraph carries a stable, document-unique `id`
(§11). The decoder generates one when missing, but **producers should always emit
unique ids** — they anchor diffs, comments, review anchors, and collaboration.

### Cached-output sidecar (a non-content companion)

A document may carry an optional **cached-output sidecar** — a derived,
non-authoritative bag of the last compute-block outputs that lets a reader paint
results immediately on open before re-evaluating. It is **not part of `content`**:

- It travels **beside** `content`, never inside it — so notebook *content* still
  persists **no** computed result (output is always derived).
- It is **outside `contentHash`** by construction (the hash covers `content`
  only), so it never affects dedup, versioning, or change detection.
- In an **export file** it MAY appear as an optional `cache` sibling of `content`
  on a document entry (see `export-envelope.schema.json`). It is a local
  performance hint, **ignored on import**. Phase-1 exporters **omit** it.
- In the app it is stored as a separate per-document object and delivered on the
  document load response; a reader that doesn't understand it simply recomputes.

Each cached value is **untrusted** and validated against a closed kind set
(`math`, `table`, `text`, `error`) and size caps before use; anything else is
dropped. A cached entry is keyed by the block id and stamped with an `inputHash`,
so a reader paints it only while it still matches the block's current input —
otherwise the block just recomputes.
