Noma Format Specification
This document defines the Noma plain-text document format at version 0.11.1. It covers the file model, lexical rules, block syntax, attribute grammar, AST shape, and renderer contracts. It is intentionally small.
Conventions
- All Noma files use UTF-8 encoding and the
.nomaextension. - Lines are terminated by
\n. Parsers must accept\r\nand normalize on read. - Whitespace at the start and end of lines inside text content is preserved; whitespace around block fences is not significant.
File structure
A Noma file consists of an optional frontmatter section followed by a body.
---
key: value
---
(body)
The frontmatter is YAML. It is exposed on the document AST as meta. If the opening --- line is missing, the file has no frontmatter.
Block syntax
Headings
# Heading 1
## Heading 2
### Heading 3
Headings auto-create section nodes. Each section has an id derived by slugifying the title. Sections nest by level: # A is a parent of ## B and ## B' until the next # C.
Heading attributes (v0.4). A heading line may end with a {...} attribute block to override the auto-slug or attach aliases:
## Risks {id="rp3-risks" aliases="risks,rp3-risk-list"}
id="..." replaces the slugified default. aliases="comma,or,space,separated" registers extra IDs that resolve to the same section — the validator accepts either, the HTML renderer emits a hidden <a id="alias"> anchor before the heading so wikilinks and URL fragments work.
Directive blocks
The core construct in Noma is the directive block. A directive opens with two or more colons followed by a name and an optional attribute list, and closes with a matching colon run on its own line.
::name{attrs}
content
::
Directive names may be namespaced for future community packs:
::finance::position{id="holding-asml"}
...
::
Children are written with strictly more colons than the parent:
::grid{columns=2}
:::card{title="Bull"}
text
:::
:::card{title="Bear"}
text
:::
::
A ::: opener inside a :: parent creates a child block. The closer must use the same colon count as its opener.
Paragraphs and inline markup
Plain text outside any directive becomes a paragraph node. Inline markup is a small subset of Markdown: **bold**, *em*, ` code , label, and the Noma extension block-id` for cross-references.
Code, lists, quotes, rules
Standard Markdown: triple-backtick fenced code, - bulleted lists, 1. ordered lists, > block quotes, --- thematic breaks.
Tables
GitHub-style pipe tables. The parser detects a row of pipe-separated cells immediately followed by a separator row.
| Column A | Column B | Column C |
| :------- | :------: | -------: |
| left | center | right |
| **bold** | `code` | [link](#)|
The separator row sets per-column alignment: :--- left, :---: center, ---: right, --- default. Inline markdown inside cells is preserved — bold, italic, code, and links all render.
The HTML renderer emits a real <table class="noma-table"> with text-align styles per cell. The LLM renderer keeps the pipe format aligned to column widths so the output stays readable in agent context.
For tables where pipe-syntax becomes ugly — single-character markers (✓, —) next to long prose cells force visible padding to keep the separator row valid — wrap the rows in a ::table directive. The separator row is no longer required, alignment is declared via an attribute, and the source stays compact:
::table{header align="-,c,r"}
| Vertical | Status | Score |
| Legal | ✓ | 3.4 |
| Healthcare | — | 2.9 |
::
align is a comma-separated list of column codes: l left, c center, r right, - default. Add the header flag to treat the first row as the header (omit it for body-only tables).
For source hygiene, run noma fmt <file> (--inplace to rewrite). It rebuilds GitHub-style pipe tables to a single column width and leaves everything else byte-identical, including tables inside fenced code blocks.
Attribute grammar
Attribute lists appear after a directive name in curly braces:
{key="quoted value" key=bare key=0.82 flag}
| Form | Type | Example |
|---|---|---|
key="text" | string | id="claim-1" |
key=word | string | tone=warning |
key=42 | number | columns=3 |
key=0.82 | number | confidence=0.82 |
flag | boolean | done |
key=true|false | boolean | pinned=true |
Attribute values are plain text. Inline markup (**bold**, *em*, ` code , link, id`) inside an attribute value is not parsed — it lands in the rendered output as a literal string. If you need rich content for a card title, evidence note, or callout heading, put it in the block body or as a child paragraph, not in an attribute. This keeps the attribute grammar trivially parseable and the AST shape predictable.
The attribute named id is special: it is promoted to the AST node's stable identifier and used by validators and the Noma Agent Protocol v1.0 RFC.
The attribute named variant is a theme hook. It lands as data-variant="..." on the rendered element so themes can style the same block class with different emphasis (important, subtle, success, danger, info are recognised by the bundled themes; custom values pass through). Use variant instead of inline styling to keep source readable. Example:
::card{title="Bull case" variant="success"}
EUV demand structurally high.
::
Core block types
Document blocks
section, paragraph, list, list_item, quote, code, thematic_break
Layout blocks
hero, grid, card, callout, columns, tabs, accordion, sidebar, button
Artifact-action blocks
export_button, control
::export_button{format="prompt|markdown|json|llm" target="..."} renders as a real <button> with format-keyed colors. The body (or Label: line) becomes the button label. Agents and end-users use these to round-trip artifact state back into a prompt or to download the underlying source.
::control{type="slider|number|text" min=... max=... default=...} renders a labeled input. First-class support for the interactive-artifact direction described in PLAN.md §23.9.
Research and reasoning
claim, evidence, counterevidence, assumption, risk, hypothesis, result, limitation, open_question, decision, adr, state_change
::state_change{block="<id>" attribute="<name>" from=<old> to=<new> reason="..." at="<iso-date>"} records a typed delta against another block — useful for weekly/quarterly recap docs that need to express "value was X, now Y". The HTML renderer shows it as a strike-through → bold delta; the LLM renderer keeps the structured fields. Validator: block= must point to an existing id; both from= and to= are required.
Data and computation
dataset, plot, metric, code_cell, output
Agent collaboration
agent_task, todo, review, comment, change_request, provenance, confidence, citation
Math
math
::math{display="block|inline" id="..."} carries a LaTeX expression. The HTML renderer wraps the body in \[...\] (display) or \(...\) (inline) and ships KaTeX assets from CDN automatically when the document contains math, unless strict rendering disables external assets. Inline math written in prose with $...$ or $$...$$ delimiters renders too — KaTeX auto-render finds and replaces them in place. The LLM renderer passes the LaTeX source through untouched so structural addressability survives.
::math{id="vol-target"}
w_{i,t} = \frac{\sigma_\text{target}}{\hat\sigma_{i,t-1}}
::
Force-disable assets with --math=none (or meta.math: false); force-enable with --math=katex.
Escape hatches
html, svg, script
Allowed but discouraged. The validator warns on every untrusted use. Add the trusted flag attribute to silence the warning on a single block; pass --no-unsafe to the CLI (or set allowEscapeHatches: false on the API) to block escape hatches entirely in trusted-publishing contexts. Pass --strict to also omit external CDN runtimes for math, diagrams, and Plotly. The LLM renderer always strips escape-hatch bodies and replaces them with a placeholder so agent context stays predictable.
::svg{trusted}
<svg width="64" height="64" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="30" fill="#b9522a" />
</svg>
::
Delimiter rule. Commas are canonical for both data and xlabels. Whitespace separators are still accepted for backward compatibility, but mixing styles between data and xlabels in the same plot triggers the plot-mixed-delimiters warning.
Dataset linkage. A ::plot may pull its data out of a sibling ::dataset instead of duplicating numbers inline. Use a dataset="<id>" attribute on the plot together with column="<name>" to select the y-series and optionally xcolumn="<name>" for categorical labels. If column is omitted, the renderer picks the first numeric column from the dataset schema.
The validator emits plot-unknown-dataset (error) when the referenced dataset id does not exist and plot-unknown-column (error) when the column is not declared in the dataset's schema. See examples/research-thesis.noma for the full pattern.
Agent context selection
noma render <file> --to llm emits deterministic plain text for model context. Use --select and --exclude to scope that context by AST node type (section, paragraph, table, code, ...) or directive name (claim, evidence, risk, dataset, ...). Selection keeps ancestor sections so the extracted blocks retain document context; exclusion prunes the matched block and its children.
noma render report.noma --to llm --select claim,evidence,risk
noma render report.noma --to llm --exclude dataset,plot --budget 12000
--budget is a maximum character count. When output exceeds the budget, the renderer trims at a line boundary when possible and appends a truncation marker.
ID registry
noma ids <file.noma|book.yml> prints a JSON registry for agent discovery:
{
"ids": ["spec", "claim-1"],
"aliases": { "intro": "spec" },
"records": [
{ "id": "spec", "type": "section", "title": "Spec", "aliases": ["intro"], "line": 1 },
{ "id": "claim-1", "type": "directive", "name": "claim", "line": 3 }
]
}
For book manifests, chapter loading applies the same book-scoped IDs and aliases used by rendering and validation, so the registry is global across chapters.
Patch transactions
noma patch --ops patch.json accepts three payload shapes:
{ "op": "update_attribute", "id": "claim-1", "key": "confidence", "value": 0.9 }
[
{ "op": "update_attribute", "id": "claim-1", "key": "confidence", "value": 0.9 },
{ "op": "add_block", "parent": "evidence", "content": "::evidence{id=\"ev-2\" for=\"claim-1\"}\n...\n::" }
]
{
"ops": [
{ "op": "update_attribute", "id": "claim-1", "key": "confidence", "value": 0.9 }
],
"prevalidate": true,
"postvalidate": true
}
The CLI writes only after every operation has applied to an in-memory candidate. When prevalidate is true, existing validation errors block the transaction before patching. When postvalidate is true, validation errors in the patched candidate block the write, leaving the source file unchanged.
Patch schemas
The CLI ships machine-readable JSON Schemas for tools that want to validate patches, AST JSON, transcript records, and capability sidecars before calling Noma:
noma schema patch-op
noma schema patch-transaction
noma schema ast
noma schema transcript
noma schema capability
The shipped patch operation set is:
| Operation | Target | Purpose |
|---|---|---|
replace_block | directive block | Replace an entire semantic block. |
replace_body | body-only directive/text node | Replace body text while preserving wrapper. |
update_heading | section | Change heading title while preserving ID. |
add_block | document/section/directive | Insert a directive child. |
delete_block | ID-bearing block | Remove a block. |
update_attribute | directive | Change one attribute except id. |
rename_id | ID-bearing block | Rename canonical ID and retarget references. |
update_heading preserves stable agent handles: when the new title would produce a different auto-slug, the source-preserving patcher adds an explicit {id="old-id"} heading attribute.
AST shape
Every node satisfies Node from src/ast.ts. The discriminated union has the following variants:
document — root, holds meta + children
section — id, level, title, children
paragraph — content (string with inline markup)
code — lang, content
list — ordered, items[]
list_item — content
quote — content
thematic_break
table — header[], align[] (left|center|right|null), rows[][]
directive — name, attrs, children, body?
Renderers exhaustively switch on type. Adding a new variant is a typed change: every renderer fails to compile until the new case is handled. Adding a new directive name (e.g., export_button, control) needs no AST change — only a renderer case.
Validator rules
The default validator detects:
- errors — duplicate IDs, references (
for=,[[id]]) pointing to unknown IDs,plotblocks with nodata=ordataset=,::memoryblocks missingtype=/id=or carrying an invalidtype/confidence/last_seen. - warnings —
evidence/counterevidencewith nofor=,figureblocks missingaltandcaption,claimblocks with no backingevidence,riskblocks with noowner=,decision/adrblocks with nostatus=,agent_task/todoblocks with noscope=or body,citationblocks whoseaccessed=YYYY-MM-DDis older than the stale window (default 365 days), and[[wikilinks]]inside amemory-profile document that resolve to a non-::memorytarget.
Per-block opt-out: add the noverify flag attribute to silence rules on a single block (e.g. rhetorical claims that aren't expected to carry their own evidence chain).
::claim{id="claim-vs-markdown" confidence=0.85 noverify}
Markdown is too weak for AI-era documents.
::
Options surfaced on the API: requireEvidenceForClaims (default true), staleCitationDays (default 365), now (test injection point for the stale-citation rule).
Per-document override. Set stale_citation_days: 30 in frontmatter to tighten the window for the whole document (newsletters, research notes, portfolio updates).
Per-citation override. Add stale_after_days=N to a single citation to give it its own window — useful when one citation is intentionally evergreen (a definition, a constitution) inside an otherwise time-sensitive doc.
Precedence (high → low): CLI --stale-days, per-citation stale_after_days, frontmatter stale_citation_days, default 365.
Profiles
A document may declare a profile in frontmatter as a contract with downstream tools: "I only use these directives." Consumers (renderers, linters, schema generators) can then narrow safely instead of switching over the full Node union.
---
profile: research
---
Built-in profiles:
| Profile | Intended for | Includes (beyond core text blocks) |
|---|---|---|
minimal | Markdown-equivalent docs | summary, abstract, callout/note/warning/tip, figure, citation, math, table |
technical | Product docs, landing pages, manuals | minimal + hero, grid, card, columns, tabs, accordion, sidebar, button, plot, dataset, code_cell, output, control, export_button, agent_task, todo |
research | Theses, ADRs, post-mortems, weekly recaps | minimal + claim, evidence, counterevidence, assumption, risk, hypothesis, result, limitation, open_question, decision, adr, dataset, plot, metric, state_change, agent_task, review, comment, change_request, provenance, confidence |
memory | Agent memory stores (per-project, per-user) | memory, memory_index only. Strict: each ::memory must declare id + type (one of user, feedback, project, reference). confidence must be a number in [0, 1]; last_seen must be an ISO date. |
math and table are content-neutral and shipped in every profile from v0.4 onward — they were the most common "out-of-profile" warnings authors hit before they learned the workaround.
The validator emits a out-of-profile-directive warning for any directive outside the declared profile (noverify on a single block silences it). Authors who want the open surface should simply omit the profile field.
Composing profiles (v0.4). A document can opt in to the union of multiple profiles via profiles: (note the plural):
---
profiles: [research, technical]
---
The validator accepts every directive listed in either profile. The legacy profile: <single> form keeps working unchanged.
Suppressing rules at the file level (v0.4). Pass --ignore-rule <name> (repeatable) to noma check and noma render to drop matching diagnostics for that invocation. Useful when validating a single chapter that contains intentional cross-book wikilinks:
noma check chapters/03-risks.noma --ignore-rule broken-reference
Unknown rule names produce an info note; they do not fail the run.
Memory profile (v0.8)
The memory profile turns a .noma file into an agent memory store — a typed, validated, patch-addressable replacement for the loose Markdown-plus-frontmatter convention most coding agents use today.
---
profile: memory
---
::memory_index{id="index"}
- [[user_handle]] — ferax564 authorship alias
- [[feedback_release_scope]] — full ship pipeline on "make a release"
::
::memory{id="user_handle" type="user" confidence=0.95 last_seen="2026-05-09"}
ferax564 is the user's public authorship handle. Not a separate GitHub account.
::
::memory{id="feedback_release_scope" type="feedback" confidence=0.95 last_seen="2026-05-10"}
"Make a release" = the complete ship pipeline, not just tag + push.
::
Why a profile rather than free directives. Memory is high-stakes context: an agent reading a malformed memory file is more dangerous than rendering a malformed thesis. The profile narrows the allowed directives to ::memory and ::memory_index, then layers stricter rules on top.
::memory attributes.
| Attribute | Required | Notes |
|---|---|---|
id | yes | Canonical, stable, patch-addressable. Treat as immutable — use rename_id only when truly necessary. |
type | yes | One of user, feedback, project, reference. Maps to the same taxonomy Claude Code's memory uses. |
confidence | no | Numeric in [0, 1]. Use it to gate dump-time recall or to skip low-confidence memories during planning. |
last_seen | no | ISO date (YYYY-MM-DD) or full ISO 8601. Drives noma render --to llm --exclude-stale-days N. |
::memory_index. A single block whose body links every memory via [[id]] wikilinks. The validator does not yet enforce index completeness, but noma check --profile memory warns whenever an index wikilink resolves to a non-::memory target — catching the most common mistake of pointing the index at a heading or section.
Canonical id ≠ display title. A memory's id= is an immutable key that agents patch against — treat it like a database primary key, not like the title of the memory. Use the body for human phrasing; if a memory needs a renamable handle, use aliases= (which the validator also resolves for wikilink targeting).
Stale-aware recall. noma render --to llm --exclude-stale-days N (optionally with --now <ISO> for tests) drops ::memory blocks whose last_seen is older than the window before emitting the LLM context dump, AND drops ::memory_index body lines whose wikilinks resolve only to omitted memories (no dangling refs in the LLM context). Time-window staleness applies only to project and reference memories by default — durable rules (user, feedback) are kept regardless of last_seen unless they explicitly carry expired=true. This is a useful first cut at bounded recall; it is not a general retrieval system. last_seen measures observation freshness, not content freshness, so a memory can be recently seen yet factually superseded — richer recall (relevance to current task, wikilink-graph expansion, valid_until / superseded_by attributes) is future work.
Surgical edits. noma patch memory.noma --op '{"op":"update_attribute","id":"...","key":"last_seen","value":"..."}' rewrites only the targeted block. Sibling memories, the index, and frontmatter survive byte-for-byte. The same applies to add_block, delete_block, replace_block, and rename_id (the last also rewrites [[wikilinks]] and reference attributes — see docs/spec-agent-protocol-v1.noma).
Single-writer assumption. Current memory patching has no expected_sha precondition or atomic-write story — concurrent writers will race at file write time. Wrap the memory file in an external lock (or a queue) if more than one agent can patch it simultaneously.
Deferred schema work. The profile validates the attributes listed above. Future MVP increments should add source, scope (so a memory written for one project doesn't bleed into another's recall), valid_until / superseded_by for status memories that go factually stale, body-non-empty enforcement, and alias uniqueness once aliases are in common use. Tombstone semantics for forget operations are also future work.
See examples/agent-memory/ for the runnable demo and npm run demo:agent-memory for the trace.
Diagrams, plotly, external datasets (v0.5)
Three new directives turn .noma into a richer artifact format without breaking the AST.
::diagram{kind="mermaid|graphviz|drawio"} — body holds the source verbatim. The HTML renderer ships a small per-kind hydrator and only injects the matching CDN runtime when the document actually contains that kind, keeping plain pages CDN-free.
::diagram{kind="mermaid"}
flowchart LR
A --> B
B --> C
::
::plotly — body is a JSON spec ({ "data": [...], "layout": {...} }). The HTML renderer injects Plotly.js and hydrates each block; the LLM renderer keeps the JSON unchanged so an agent can still reason about the chart.
::dataset{src="data.csv"} — paths resolve relative to the source file (book mode resolves per chapter). The CLI calls inlineDatasetSources after parse so renderers stay pure: by the time HTML/LLM/JSON run, the dataset body is already inlined and the format attribute is set (csv, tsv, json, yaml). Plots reference these datasets by id and column exactly as before.
Source-preserving patch (v0.5)
noma patch no longer round-trips through renderNoma over the whole file. It now rewrites only the targeted line range — frontmatter quoting, sibling blocks, blank-line padding, and attribute order on unchanged lines all survive intact. The programmatic API exposes the new function as patchSource(source, ops); the AST-level patch(doc, op) stays for callers that already work in AST space. For compatibility rules and bundled schemas, see Noma Compatibility Policy. For the locked v1.0 protocol core, see the Noma Agent Protocol v1.0 RFC.
Render targets (v0.11.0)
| Target | Status | Notes |
|---|---|---|
html | stable | Self-contained, semantic, with print stylesheet. Auto-injects KaTeX for math. |
llm | stable | Deterministic, LLM-friendly text |
json | stable | Full AST |
noma | stable | AST → .noma source (roundtrip-safe; backs noma patch) |
site | stable | Multi-page HTML site for book manifests; one page per chapter + index |
pdf | shipped | Puppeteer (HTML → Chromium print). See npm run render:pdf:demos |
epub | planned | |
slides | planned |
--to site
Pass a book manifest to noma render <book.yml> --to site --out <dir>. The renderer writes one <chapter-slug>.html per chapter plus an index.html table-of-contents. Chapter slugs come from the chapter filename (or the chapter root H1's id when the filename is unstable). Cross-chapter wikilinks [[block-id]] resolve into <other-chapter>.html#block-id URLs, with same-page references staying as #block-id. Each page gets a top nav listing every chapter and a back-link to the index.
Book manifests
Multi-file projects use a YAML manifest with a .yml / .yaml extension.
title: Agentic Documents
author: ferax564
outputs:
html:
theme: default
llm: {}
chapters:
- chapters/01-introduction.noma
- chapters/02-block-model.noma
- chapters/03-agent-edits.noma
Chapter paths are resolved relative to the manifest directory. The CLI auto-detects the manifest by extension:
noma render book.noma.yml --to html --out dist/book.html
noma render book.noma.yml --to llm --out dist/book.llm.txt
noma check book.noma.yml
The loader concatenates each chapter's parsed AST into a single DocumentNode (top-level # Chapter headings stay distinct so HTML renders one continuous page with chapter sections; LLM export keeps chapter boundaries via the heading prefix). The manifest's title / author land on document.meta; the outputs.html.theme value is honoured by future renderers and ignored for now.
Scoped heading IDs (v0.4). In book mode, every level ≥ 2 heading has its slug path-prefixed by its chapter root: ## Risks inside # Risk Premia 3 becomes risk-premia-3/risks. The original (un-prefixed) slug is kept as an alias so legacy [[risks]] links still resolve. This eliminates the duplicate-id floods that 30-chapter books hit when every chapter has ## Risks + ## Citations. Single-file mode behaves exactly as before.
Chapter aliases (v0.4). Two extra resolution paths land on the chapter root section:
- Filename slug.
chapters/risk-premia-3.nomaaddsrisk-premia-3as an alias for the chapter —[[risk-premia-3]]resolves regardless of the H1's spelling. - Frontmatter
aliases:. A list of strings on the chapter that registers extra IDs:
``yaml
---
title: Risk Premia 3 (RP3)
aliases: [rp3]
---
``
After this, [[rp3]], [[risk-premia-3]], and [[risk-premia-3-rp3]] all resolve to the same section.
Resolution order: explicit id= on the heading → auto-slug from the title → frontmatter aliases: → filename slug. Any of those paths matching the wikilink target counts as resolved.
Compatibility promises (v0.11.0)
- Public npm package names live under the
@ferax564scope:
@ferax564/noma-cli, @ferax564/noma-mcp-server, and @ferax564/noma-agent-sdk. The @noma/* npm scope belongs to a different project and must not be used in install instructions or generated package metadata.
Compatibility promises (v0.10.1)
- The root GitHub Action installs the CLI from the checked-out action ref by
default. This keeps uses: ferax564/noma@vN.N.N pinned to that release even if the npm registry package configured by an override points somewhere else. Explicit cli-package and deprecated cli-version overrides remain available for advanced workflows.
Compatibility promises (v0.10.0)
noma --version/noma -v,noma init,noma ids,noma render --strict,
and scoped LLM export flags (--select, --exclude, --budget) are additive CLI surface. Existing commands and render output remain compatible unless a caller opts into one of the new flags.
noma patch --opsaccepts transaction-shaped payloads with optional
prevalidate / postvalidate. Single-op --op payloads remain supported. Transaction writes are all-or-nothing when validation fails.
- Strict rendering blocks raw escape hatches and external runtime assets. The
default HTML renderer remains permissive for local artifact authoring; use --strict or manifest trusted-publishing mode for team/published contexts.
- The reusable GitHub Action is a distribution wrapper around the CLI, not a new
format contract. Pin cli-version in workflows that require reproducible CI output.
Compatibility promises (v0.8)
- The
memoryprofile is stable.::memoryrequiresid=+type=(one of
user, feedback, project, reference); confidence must be a number in [0, 1]; last_seen must be an ISO date. The four type names will not be removed within 0.x — additive new types may be added.
noma render --to llm --exclude-stale-days Nis additive. Durable types
(user, feedback) stay pinned regardless of last_seen unless they carry expired=true; only project and reference memories age out of the recall window. --now <iso> overrides "today" for tests.
--to sitefrom a nested-slug chapter (level-1idcontaining/) now
emits depth-aware ../ prefixes on nav chapter links, the home link, and cross-chapter wikilink hrefs — matching the v0.7.1 stylesheet behaviour. Flat-slug books are byte-identical to v0.7.1.
Compatibility promises (v0.7)
noma diff a.noma b.nomais additive and pure: same inputs always produce
the same ::state_change block list. Output schema matches v0.3's ::state_change directive (block, attribute, from, to, at, optional reason).
book.ymltrusted_publishing: trueimplies--no-unsafefor all renders
driven by that manifest. Existing manifests without the field are unchanged.
--to siteemits_assets/theme.cssonce and links it from every page.
Output of renderHtml (single-page) is unchanged unless the caller opts in via stylesheetHref.
Compatibility promises (v0.6.0)
- The directive opener/closer syntax is stable and will not change in 0.x.
- Attribute grammar is stable.
- Auto-generated heading IDs (slugify rules) are stable.
- The AST is not stable across 0.x — new fields may be added on existing variants. Renderers and validators inside this repo move in lockstep.