3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00
Files
adf-codec/docs/decisions.md
T

1299 lines
81 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Decisions
## Each call names its flavour
2026-10-09, the maintainer; names by a developer panel, 2026-10-09. Goals 4, 5 and 6.1. Valid while
each flavour has a standard or a spec of its own.
A function's name says which markdown it reads or writes, and a reader reads that flavour alone:
| Call | Flavour |
| --- | --- |
| `adfToLosslessMarkdown`, `losslessMarkdownToAdf` | the lossless flavour, `spec/flavour.md` |
| `commonMarkToAdf` | CommonMark as its spec says: a directive, a pipe table, `~~`, `==` and an `adf:` fence read as CommonMark reads them |
| `adfToPortableMarkdown`, `portableMarkdownToAdf` | CommonMark, GFM's pipe tables, `~~`, alerts and task markers, and Obsidian's callouts and `==`; no directive and no `adf:` fence |
The calls between markdown and HTML that `todo.md` item 139 ships take their flavour's name too. No
`adfToCommonMark`: the portable writer serves every persona a CommonMark writer would. The panel
picked `portable` 4 of 7 over `plain` 3, `gfm` and `rendered` none: the export readers read it as
"renders anywhere, lossy", and three readers read `plain` as holding no tables or alerts.
## Portable markdown is a flavour of the grammar
2026-09-27, the maintainer. Goal 2. Valid while the portable flavour's spellings are ones the
markdown grammar can read and write.
The lossy pair is the portable flavour: the markdown grammar's reader and writer with the flavour
set, its spellings — alerts, callouts, task markers, `==` — read and written there, so a marker line
and a backslash reach them intact; what the flavour cannot spell reduces ADF→ADF ahead of the
writer.
## The round-trip is the product
2026-08-23, real payloads 2026-09-15, the maintainer. Goal 1. Valid while a consumer saves back
through the lossless pair.
`losslessMarkdownToAdf(adfToLosslessMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must deep-equal
`doc` — anything less silently destroys content an editor could not represent, in a document it did
not author. When losslessness and readability conflict, losslessness wins. Round-trip equality is a
property tested over a checked-in corpus (`corpus/README.md`), not a claim made in prose. Its real
payloads are invented content written in Atlassian's editor on the maintainer's test site, so none
is sanitized and a mention keeps the test user's real account id.
## Markdown in is a canonical fixpoint
2026-08-23, the maintainer; every parsed document has converted back since 2026-10-04. Goals 1 and
5. Valid while markdown input may be written by hand.
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
back yields the library's canonical spelling, and that spelling round-trips byte-identically.
## Equality is deep
2026-08-24, deep 2026-10-03, the maintainer. Goal 1. Valid while a pipeline or a bot can build a
shape the editor would not.
"Equals" is deep equality over the document's JSON values: `assert.deepStrictEqual` on plain objects
as `JSON.parse` builds them, since ADF is JSON. Every key and value in `doc` counts, including two
adjacent text nodes, an empty `attrs`, `content` or `marks`, and `-0`. Neither side is normalized.
CommonMark's spelling stays wherever a document holds none of those shapes. The portable reader
builds what is written, as `losslessMarkdownToAdf` does. Only the portable writer is lossy: its
reduction reads and writes editor-normal ADF — adjacent text nodes of identical marks and no
attributes merged, `-0` as `0`, and an empty `attrs`, `content` or `marks` the absent key, except
the doc's `content`, which ADF's schema requires — so two documents the editor holds equal write the
same portable markdown.
## `!adf:textBreak{}` parts text CommonMark would join
2026-10-03, the maintainer. Goal 1. Valid while CommonMark reads adjacent text as one run.
Two adjacent text nodes CommonMark would read back as one are parted by the reserved inline leaf
`!adf:textBreak{}`: a leaf building no node keeps both nodes and asks nothing of the text around it.
A code span holds no directive, so the spans close and reopen around the leaf. The grammar:
`spec/flavour.md` §Inline nodes, **Adjacent text nodes**.
## A list right after one of its kind takes the other marker
2026-10-04, ordered lists in portable markdown 2026-10-09, the maintainer. Goals 4 and 5. Valid
while CommonMark starts a new list where the bullet character or the ordered delimiter changes.
Every reader parts lists where the marker changes, as CommonMark does, so both writers alternate the
marker between adjacent lists of a kind and no directive parts them. `*` is the maintainer's pick.
For ordered lists in portable markdown, this overrides a reader panel's 2026-09-25 verdict, 5 of
7, for one bullet list with escaped numbers (`- 1\. a`). The panel chose that while no other
portable spelling parted the lists, so the maintainer overrode it without a new panel. The grammar: `spec/flavour.md` §Canonical form.
## An empty key spells `empty`
2026-10-03, a writer panel and the maintainer. Goals 1 and 6. Valid while no attribute value
spells an empty object or array.
An `attrs`, `content` or `marks` key holding an empty object or array is the reserved key with the
bare value `empty`, so a container opener and closer with nothing between them stays the node
holding no `content` key. A writer panel chose the spelling, 5 of 7. The grammar: `spec/flavour.md`
§Directives, **Attributes**, and §Marks.
## `-0` is spelled `-0`
2026-10-03, the maintainer. Goal 1. Valid while JSON's own serialization writes `-0` as `0`.
`-0` is spelled `-0` wherever the flavour writes a number or a JSON value, since JSON's grammar
reads it back as `-0`. The grammar: `spec/flavour.md` §Block nodes and §The CommonMark blocks.
## A block's marks are named, and `colwidth` is a comma list
2026-10-03 for `colwidth`, 5 of 7, and 2026-10-14 for `marks`, 3 of 3: writer panels of product
managers, engineers and support agents. Goal 6. Valid while ADF types these attributes as
`src/adf/` does.
`colwidth` is its widths joined by commas, `colwidth="340,420"`, where it was a JSON array. A
block's `marks` names each mark with its `{attrs}`, in order,
`marks="indentation{level=2} fragment{localId=f1 name=\"Price list\"}"`, chosen over the escaped
JSON array and the JSON array unquoted. All three writers first sketched a key per mark,
`{align=center}`, which loses the marks' order and can clash with the node's own attributes. Where
`src/adf/` does not type a mark or one of its attributes, or a value holds another type, the whole
array stays JSON, the general form (§Readable spellings take the `try` prefix).
The grammar: `spec/flavour.md` §Block nodes.
## Empty markdown is a document of no blocks
2026-10-03, a writer panel and the maintainer. Goals 1 and 6. Valid while ADF's schema requires
`content` on `doc`.
Markdown holding no block reads as `{ content: [], type: 'doc', version: 1 }`, the document
`spec/adf-schema/full.json` requires. A document holding no `content` key is
`!adf:doc {content=none}` as its only block, and a named error anywhere else. The writer panel split
4 for `none` and 3 for `absent`, and the maintainer chose `none`; all seven rejected a bare
`!adf:doc`.
## Unknown nodes ride the carry
2026-08-23, extended to misplaced known nodes 2026-08-26 and to code block children 2026-10-03, the
maintainer. Goal 1. Valid while ADF holds nodes, or node positions, this library does not spell.
An unknown ADF node is carried opaquely — raw JSON rides a dedicated syntax in both formats and
restores to a deep-equal node. The round-trip holds for documents newer than the library. So does
a known node no section spells where it stands: a markdown serializer spells a node by type without
checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`. A
`codeBlock` holding a child no fence holds — anything but a text node carrying no marks, `attrs` or
`content` — rides the carry whole.
## The carry fence names the node type
2026-10-03, the maintainer. Goals 1 and 6. Valid while a code fence's info string reads back
verbatim.
The block carry is a code fence whose info string `adf:<type>` names the node's type, its body the
node's JSON without `type`: ```` ```adf:blockCard ````. A type no info string carries back — by the
rule a code language follows — leaves the info string `adf:` and keeps `type` in the body. Every
info string opening `adf:` is reserved, so a `codeBlock` whose language opens so takes the
`language` attribute, and `carry` is an ordinary language. A body holding `type` under a named type,
or a fence whose info string is `adf:` alone while its body's `type` could be spelled in the info
string, is `unsupported-node-shape`. The reservation is the lossless flavour's alone.
## A code block is a fence per text node
2026-10-03, the maintainer. Goal 1. Valid while ADF holds a code block's text in more than one
node.
A `codeBlock` holding several text nodes is the `!adf:codeBlock` container holding one fence per
node, so each node keeps its own text. The fences carry one info string, since ADF holds one
language, and none is empty beside another, since a text node holds text. The grammar:
`spec/flavour.md` §The CommonMark blocks, the `codeBlock` bullet.
## Foreign HTML sorts three ways
2026-08-23, the sort 2026-09-20, the maintainer. Goals 1 and 5. Valid while ADF holds no node
for a bare container, a comment or a script. Lands with `todo.md` item 137.
Every foreign element `htmlToAdf` and the markdown readers read sorts one of three ways, never a
silent drop of content:
- A container around document content that ADF has no node for unwraps to its children, its own
attributes dropped: `<div align="center">text</div>` keeps `text`, losing the alignment.
- Content ADF cannot hold is an error result naming it. A comment is one: a person wrote those
words, and neither of Atlassian's schemas holds them — `annotation`'s `inlineComment` carries an
id, `placeholder` is the editor's own hint, `extension` names a vendor app.
- What is not document content drops whole: `<script>` and `<style>`, their text with them.
`<details><summary>Title</summary>…</details>` is an `expand` titled by its summary, a
`nestedExpand` inside another; an empty one is refused, since `expand` requires content. A `style`
attribute is not read at `0.4.0`: the `textColor` and `backgroundColor` it could reach cost more
than they buy.
The three markdown readers share one parser, so they take the same set.
## Names stay text
2026-08-23, the maintainer. Goal 8. Valid while resolving a name to an id needs I/O.
A bare `@name` or `:smile:` in typed text stays a text node. Only directives produce
mention/emoji/media nodes; resolving names to ids is the consumer's job.
## Directives under `!adf:`
2026-08-23, prefixed `!adf:` 2026-09-16, the maintainer. Goals 5 and 6. Valid while prose does not
write `!adf:`.
Directives are one grammar for everything markdown lacks, namespaced under `!adf:`:
`!adf:panel info` … `!adf:/panel` blocks, `!adf:mention[@Mikael]{id=5b10a2}` inline, `\!adf:` the
one escape. Not CommonMark's generic-directives proposal: its `:::` claims a form prose writes, and
its fence-length discipline ties a container's opener to its own body, where closing from the
opener nests by itself and leaf versus container falls out of the node's content model.
## CommonMark is a subset
2026-08-23, the maintainer. Goal 5. Valid while prose rarely writes the shapes the carve-outs claim.
CommonMark is a subset of the lossless flavour, with carve-outs (`spec/flavour.md`): literal text
shaped like a directive, a pipe table or a `~~` pair is claimed, and so is a code fence whose info
string opens `adf:` (§The carry fence names the node type). `commonMarkToAdf` claims none of the
carve-outs (§Each call names its flavour).
## Tables
2026-08-23, the maintainer. Goal 6. Valid while a pipe table holds only one header row and inline
cells.
One header row plus plain inline cells → pipe table; anything richer → directive form.
## Links
2026-09-13, nesting 2026-09-17, the maintainer. Goals 1 and 6. Valid while CommonMark's link
syntax is what readers edit.
`[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark spells the
mark; `!adf:link[text]{attrs}` where it does not — an attribute CommonMark cannot hold, an `href` or
`title` no canonical escape spells, a paragraph opening whose CommonMark spelling would read as a
link reference definition — and a directive link CommonMark could spell is refused. No link wraps a
link — the bracket form goes literal, the directive form refused — which is CommonMark's prose
where its reference implementation nests one `<a>` in another.
## An image reads as an image only alone in its paragraph, else as its alt text linked; a link text of hard breaks alone reads as its destination, then the breaks
2026-10-04, the maintainer and a writer panel, 3 of 3; `[]()` 2026-10-09, by Goals 4 and 7, as the
maintainer's plan item asked; a link text of hard breaks alone 2026-10-09, the maintainer; an image
inside a link 2026-10-22, a reader panel, 3 of 3 and 5 of 7. Goals 4, 5, 6 and 7. Valid while
`mediaInline` carries no URL, ADF holds no empty text node, a hard break takes no marks and no
consumer has reported a link of hard breaks alone.
Every reader reads every CommonMark image and empty link, as `spec/flavour.md` §The CommonMark image
and §Marks spell out. An image alone in its paragraph reads as the image, its title as the caption.
Anywhere else it reads as its alt text linked to its URL, titled by its title. An empty alt text
reads as the URL, and an empty link text as its destination. A link text holding only hard breaks
reads as its destination, then those breaks: four of seven readers expected that over dropping them.
`[]()` reads as nothing: it renders nothing, so Goal 7 loses no content. A link alone in its
paragraph whose whole text is one image reads as that image, with the link marking its `media`.
Inside any other link, the alt text is plain text the link marks. After the link comes ` (image)`,
where `image` links to the image's URL and carries its title, so Goal 7 keeps both. Three readers,
the bot, the LLM pipeline and a support agent, picked this 3 of 3 for a link whose text is the image
alone. For a link with words beside the image, the three split on where `(image)` goes. Four fresh
readers joined: a second bot, a second LLM pipeline, a product manager and an engineer. Five of the
seven put `(image)` after the whole link rather than right after the alt text. Every reader rejected
linking the alt text to the image, since the words would lose the link they were written in.
A link to the image's own URL takes no `(image)`, since the link already reaches the image. The
exception is an image whose title the link lacks: there `(image)` keeps the title.
In `portableMarkdownToAdf`, a `[!WORD]` marker with no `+` or `-` stands apart from the lines after
it, and so does a `+` or `-` callout's title line, so an image alone on the marker line or after the
title reads as the image: a reader panel of the bot, the LLM pipeline and a support agent, 3 of 3
on 2026-10-09, expected the picture in every case, a ticked task included. A task item holds no
image in Atlassian's schema, so the paragraph a task marker opens reads its image as linked alt
text, the fallback two of the three named.
The same panel confirmed the other readings on 2026-10-09: a title as the caption, an image amid
text as linked alt text, and an empty link text as its URL, each 3 of 3; `[]()` as nothing, 5 of 7.
Their CommonMark suite exceptions are `node-model` (§The CommonMark suite checks three ways).
The title's caption, and the reader panel of 2026-10-18 that tested it, are in §An image's title is its
caption when the image stands alone in its paragraph.
## An image's title is its caption when the image stands alone in its paragraph, since a title counts as content
2026-10-18, the maintainer. The 2026-10-09 panel read a title as the caption, 3 of 3, and the
2026-10-11 writer panel read it as hover text, so a reader panel tested the reading again. Seven
readers split: two picked the caption, two the picture alone, and three neither. Those three
described hover text. No output reached the five of seven that settles a panel, so Goal 7 decided:
a title counts as content, so it keeps its words. ADF holds no hover text for an image, so the
caption is the one ADF node that keeps them. Valid while a link's or an image's title counts as
content (`README.md` `## Goals`, Goal 7).
## A mark skips a node that cannot hold it, and a directive mark wrapping only such nodes is refused, as is a `[text](url)` link unless its text is hard breaks alone
2026-10-14, two reader panels of the viewer/editor app, the LLM pipeline and a support agent, 3 of 3
each. Goals 1, 4 and 6. Valid while Atlassian's schema withholds marks from inline nodes. Extended
to code text under this rule, without a panel, 2026-10-14; retitled by the maintainer the same day,
keeping the directive-mark refusal the old title held. Extended to `border` on text, which the
schema withholds from text, 2026-10-31, without a panel.
A mark spelling skips a node that takes none of its type. Code text takes only `code`, `link` and
`annotation`, and no text takes `border`. So
`**Ping !adf:mention[@A]{id=x} now**` reads as bold text around a plain mention, and
``**`npm ci`**`` as plain code. A directive mark wrapping only nodes that take none of its type
is `unsupported-node-shape` in `losslessMarkdownToAdf`. So is a link whose text holds something
besides hard breaks but no node that takes a link: the panel refused to have
`[!adf:status[Done]{color=green}](/u)` read as `/u` linked beside the status. A link text of hard
breaks alone still reads as its destination, then the breaks (§An image reads as an image only alone
in its paragraph). `adfToLosslessMarkdown` writes a node holding a mark its type does not take as
`!adf:carry`, so the round-trip holds, except an inline card a link marks, which is refused
(§Schema refusals are made in the first step alone).
## A linked image is written `[![alt](url)](href)`, a captioned image as its directive
2026-10-11, a writer panel of a product manager, an engineer and a support agent, 3 of 3 on each.
Goal 6. Valid while the audience reads a title as hover text.
`adfToLosslessMarkdown` writes a `media` whose only mark is a link as `[![alt](url)](href)`, which
each reader expected before seeing the options. A captioned image keeps the `!adf:caption`
directive: each reader read `![alt](url "title")` as hover text, not a caption. The library's readers
build a title as the image's caption when the image stands alone in its paragraph, since a title
counts as content (§An image's title is its caption when the image stands alone in its paragraph).
## A linked image standing inline in portable markdown, its link to another target, is written `[alt](href) ([image](url))`
2026-10-14, a reader panel of the export tool, the LLM pipeline and a support agent, and a writer
panel of a product manager, an engineer and that support agent, 3 of 3 each. Goals 6 and 7. Valid
while ADF holds no inline external image, so the portable reader reads an image inside a link as
linked text.
Where `adfToPortableMarkdown` writes an external image inline, as in a table cell, and a link to
another target marks it, the link keeps its target and the image follows as a link reading `image`.
With no alt text, the link text is the link's target, which is how the reader reads `[](href)`, so
the rewrite holds. Every seat first wrote `[![alt](url)](href)`, but the reader reads that as
`[alt](href) ([image](url))`, so writing it breaks the byte-for-byte rewrite. Each then picked this over
`[alt](url)` and `[alt](href)`, which drop a target.
## Ids stay site-local
2026-08-23, the maintainer. Goal 1. Valid while ADF ids are minted per site.
Identity-bearing nodes carry their ids in attributes; a document is only portable within its site —
accepted.
## Portable task ids come from position
2026-09-26, spelling 2026-09-29, the maintainer. Goals 7 and 8. Valid while a site rejects a task
node with no `localId`.
`portableMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` a `localId` in the
editor's UUID v4 shape, hashed from the whole markdown and the node's order among those it mints:
the same markdown reads to the same ids every run, different markdown to different ids. The same
markdown pasted twice into one document repeats its ids: determinism wins over that case.
## A block a task item cannot hold stands after its task list
2026-10-09, a reader panel, 6 of 7. Goals 4, 6 and 7. Valid while Atlassian's schema holds a
`blockTaskItem` to paragraphs and extensions.
In `portableMarkdownToAdf` and `adfToPortableMarkdown`, a task item keeps its paragraphs, then its
nested task lists. The rest of the item stands after the task list. The list resumes at the next
task. The panel of two bots, two LLM pipelines, a support agent, a product manager and an engineer,
shown an image, a code block and a nested bullet list in a task item, picked this over turning those
blocks into text inside the item (1 of 7) and over a plain bullet list keeping the markers as text
(none): every reader wanted the checkboxes, and six wanted the blocks in their own form.
## A callout title keeps its link targets
2026-09-29, the maintainer. Goals 6 and 7. Valid while an expand's `title` is a string.
`portableMarkdownToAdf` writes a link in a folded callout's title as its text and its target in
parentheses: `> [!faq]- See [x](http://y)` reads to the title `See x (http://y)`. A link whose text
is its target, with or without `mailto:`, keeps its text alone: `<http://y>` titles `http://y`,
`<a@b.c>` `a@b.c` — three persona readers agreeing, 2026-09-30.
## The portable flavour's spellings
2026-09-14, panels 2026-09-25 and 2026-09-29, the maintainer. Goals 6 and 7. Valid while GitHub's
renderer is the one the audience's markdown is read in.
README §Portable markdown's rows come from a survey of GitHub, GitLab, Gitea, Obsidian, Pandoc,
MkDocs, Docusaurus, Typora, Joplin, Logseq, Bear, Notion, Azure DevOps and Discord, GitHub's
renderer confirming each shape. Reader panels settled `error` as an error panel, the `==` bounds
(3 of 3) and a Han, Hangul, kana, Thai, Lao, Khmer or Myanmar character on either side bounding a
delimiter, so `は==日本語==で` (3 of 3), `==한국어==에서만` and `iPhone==専用==` (6 of 7) highlight,
the external image's two forms (6 of 7), a rule opening a list item dropping and the omission
notes (3 of 3), and a list's numbering overflowing into bullets (3 of 3).
An omission note reads as the converter's, never as the author's.
Reading takes other tools' spellings, since it reads their output and writes none of them.
Rejected: `~sub~` and `^sup^` (`~2~` is a strike on GitHub, so `subsup` drops), underline and colour
spellings, raw HTML (`<details>`, `<mark>`), MkDocs `!!!` and the `:::` admonition family,
footnotes, definition lists, wikilinks, embeds, tags, comments, TOC tokens, spoilers, task states
past `[x]`/`[ ]`, and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes.
## The portable reader refuses what GFM's reading would drop
2026-10-14, the maintainer; the ruling of Goal 7 over Goal 4, 2026-10-17, the maintainer. Goals 4
and 7. Valid while a refusal is a result and the caller can fix the markdown it sent.
Where GFM's reading of a shape drops content, `portableMarkdownToAdf` refuses the shape. A pipe
table body row holding more cells than its header is `malformed-pipe-table`, where GFM cuts the
extra cells. Cutting the cells breaks Goal 7. Building a table GFM does not, such as a wider one,
breaks Goal 4. A refusal builds no table, but it still breaks Goal 4: a bot gets an error for a row
GitHub renders. Goal 7 is a Maintain goal, holding whatever it costs, so it wins over Goal 4, an
Achieve goal.
## The portable reader reads what GFM's reading keeps
2026-11-09, plan item 112, approved by the maintainer. Goals 4 and 7. Valid while GFM's reading
of a shape drops no content.
Where GFM's reading of a shape drops nothing, `portableMarkdownToAdf` reads it as GFM does. A pipe
table's short body row is padded with empty cells, a line holding no `|` that opens no other
block is a body row of one cell, and `a |` over `--- |` is a one-column table. `losslessMarkdownToAdf`
keeps its stricter reading (§The portable reader refuses what GFM's reading would drop).
Padding builds cells the markdown never holds, so a header of N columns over M one-cell rows would
build N×M cells from N+M cells of source, against Goal 9. `portableMarkdownToAdf` pads a table only
while the cells the padding adds stay within the cells the table's source holds (header, delimiter
and body rows); past that, the table is `malformed-pipe-table`, and the markdown writes the empty
cells out. The output then stays within twice the input's cells, and the bound needs no number of
its own.
## The HTML dialect
2026-08-23, the maintainer; Atlassian's HTML+ 2026-10-28, the maintainer. Goals 6 and 8. Valid
while HTML output is read by consumers styling it themselves, and while Atlassian publishes no spec
for HTML+, its Rovo MCP server's HTML for agents; a published spec reopens the goals.
The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*`
for what HTML cannot express, text always escaped. No stylesheet ships.
The spellings, `spec/html.md`, are six writer panels' (2026-10-23, -24, 2026-11-04 and -05), each of a
product manager, an engineer and a support agent. The first picked each of these 3 of 3:
- an image standing alone as `<p><img …></p>`, over `<figure>` and a bare `<img>`;
- a list item's first paragraph bare, `<li>Bolt</li>`, over `<li><p>Bolt</p></li>`;
- `class="language-js"`, over `lang-js`;
- `<br>`, over `<br />`.
Seven readers picked `data-adf` for the carry element, 6 of 7, over `data-adf-node`. Four wanted the
class `adf-carry` and three no class; this entry's stable `adf-*` classes settle it.
Atlassian's editor puts a `localId` on every block, so carrying each block holding one would leave
no element at all. The second panel picked each of these 3 of 3:
- an attribute the element cannot express as `data-local-id`, over `data-adf-local-id` and `id`;
- a link inside a carry element as `<a>`, over `text (url)` and its text alone;
- a carried node's blocks as `<p>`, over `<br>` and a line feed;
- media a carry element cannot show as `<em>(image)</em>`, over `<em>(image not shown)</em>` and
nothing.
A third panel, seven readers in the same three roles, was asked how to write a list item whose first
paragraph holds an attribute, the shape of every item Atlassian's editor writes. It picked
`<li data-local-id="i1"><p data-local-id="p1">Bolt</p></li>`, 6 of 7, over the same elements with
the `<p>` on a line of its own, 1 of 7, and over
`<li data-local-id="i1" data-paragraph-local-id="p1">Bolt</li>`, 0 of 7.
A fourth panel of the same three roles picked, 3 of 3, a code block inside a carry element as
`<pre><code>…</code></pre>` over `<pre>…</pre>` and over a `<p>` whose lines part at `<br>`, which
loses the indentation. All three wrote `<code class="language-python">`, which no option held, so
the shown code keeps the class its code block's own element takes.
Inside a carry element, an image marked by a link to another target shows as portable markdown
writes it inline (§A linked image standing inline in portable markdown, its link to another target,
is written `[alt](href) ([image](url))`), so both targets stay; Goal 7 counts them as content. A link
marking a node that shows a note goes around the note. Recorded 2026-10-24 without a panel of its own:
it joins the second panel's `<a>` to that portable spelling.
HTML nests no `<a>` in an `<a>`, so no element can hold both a card's URL and the target of a link
marking it. Every writer refuses such a card, and a carry element holding one, as the maintainer
chose on 2026-11-04 over a new spelling (§Schema refusals are made in the first step alone).
A fifth panel (2026-11-04, three readers in the same roles, then four more for what the first three
split on) was asked how to write a mention, emoji, status, date and inline card, which until then
rode the carry. The first three picked each of these 3 of 3:
- an emoji as `<span class="adf-emoji" data-id="1f389" data-short-name=":tada:">🎉</span>`, its text
the content, over an `<img>`;
- a status as `<span class="adf-status" data-color="yellow">In review</span>`;
- a card holding a URL as `<a class="adf-inline-card" href="https://example.com/spec">https://example.com/spec</a>`,
over a `data-url` beside the `href`.
Seven readers picked a date as
`<time class="adf-date" datetime="2025-08-25" data-timestamp="1756080000000">2025-08-25</time>`,
5 of 7: the day for people, the exact timestamp for scripts. Two questions stayed split. A mention's
`data-id`, `data-access-level` and `data-user-type` drew 4 of 7, over `data-mention-id` 2 and
`data-user-id` 1; a card holding `data` and no URL drew `<a … href data-data>` 3, `<a … data-card>` 2
and `<span … data-data>` 2. The maintainer took the leading spelling of each, 2026-11-04:
- a mention is `<span class="adf-mention" data-access-level="CONTAINER" data-id="5b10a2" data-user-type="DEFAULT">@Mikael</span>`, its `text` the content;
- a card holding `data` and no URL is `<a class="adf-inline-card" href="https://example.com/plan" data-data="{…}">Release plan</a>`, `href` the URL its `data` holds and the text its name, else that URL. A card holding both `data` and a `url` rides the carry: its `href`, read back, could not say which of the two URLs it spells.
A sixth panel (2026-11-05, three readers in the same roles) picked each of these 3 of 3 for a table:
- a first row of header cells alone in `<thead>` and the rest in `<tbody>`, a row to a line, over a
bare `<table>` of `<tr>` rows and over a line per cell;
- a cell's first paragraph bare, `<td>Bolt M8</td>`, as a list item's;
- a cell's attributes as `<td colspan="2" rowspan="2" style="background-color:#fffae6" data-colwidth="120,80">`,
over `data-*` for all four.
## `htmlToAdf` also reads the dialect as a browser serializes it
2026-10-28, the maintainer. Goal 6. Valid while a viewer/editor app may show `adfToHtml`'s HTML in a
browser and post the page back.
A browser does not write the dialect back as `adfToHtml` wrote it. `htmlToAdf` reads both spellings:
a page's `innerHTML`, as WHATWG HTML §Serializing HTML fragments spells it, and a form post, which
ends each line in `\r\n`. In text, a raw `"` reads as `&quot;` does; in an attribute value, a raw `<`
or `>` reads as `&lt;` or `&gt;` (a browser from before the algorithm escaped them writes them raw);
in both, `&nbsp;` reads as a no-break space; and `\r\n` reads as a line feed. All of it holds in what
a carry element shows too. These are every difference the algorithm leaves between a browser's HTML
and `adfToHtml`'s, checked against Firefox's own `innerHTML` in the browser leg, and they hold for
every element the dialect writes, later ones included. Other HTML stays refused, a lone `\r` among
it. A refusal's position counts the HTML as handed in.
## No runtime dependencies
2026-08-23, the maintainer. Goal 8. Valid while ~20 lines of own code, or a vendored table, do each
job a dependency would.
`dependencies` is empty. A runtime dependency enters only through an entry here stating why ~20
lines of own code cannot do the job, who maintains it, and what auditing it costs. So the CommonMark
and HTML parsers are written in this repo.
## Standards ship as data
2026-08-30, the CommonMark suite 2026-09-05 and ADF's schemas 2026-09-13, the maintainer. Goals 1,
5 and 8. Valid while each table is fixed data a dependency would only wrap.
A table a standard fixes is data rather than a dependency: HTML5's 2125 semicolon-terminated
character references ship packed in their own module, so entity decoding is complete without one.
The CommonMark spec suite is the same shape of data and ships vendored at `corpus/commonmark-spec/`
rather than as the `commonmark-spec` dev dependency — that package is CommonJS-only, and Renovate
auto-bumping a spec version would silently point the vendored exception list's example numbers at a
renumbered suite. A spec bump is a deliberate re-pin, exceptions re-derived by hand beside it.
Atlassian's ADF JSON Schemas ship vendored the same way, at `spec/adf-schema/`, rather than as the
`@atlaskit/adf-schema` dev dependency — CommonJS-only, some fifty packages with React among them,
and a release most days for Renovate to automerge — re-pinned by hand when a payload or a report
shows the need.
## fast-check
2026-09-14, the maintainer. Goal 1. Valid while a failing generated document needs shrinking by
hand otherwise.
`fast-check` earns its place as a devDependency shrinking a failing generated document to the
nodes that break it.
## Stryker in its own package
2026-10-28, the architecture review. Code health: the code is easy to change safely. Valid while the gate runs no
mutation tests.
Mutation testing with Stryker lives in `mutation-tests/`'s own package, so the gate's `npm ci` never
installs it.
## Any ES2022 engine
2026-09-01, the maintainer. Goal 8. Valid while ES2022 is the floor browsers and servers share.
The library runs on any ES2022 engine, not only Node — a browser as readily as a server. The
shipped source is ECMAScript and nothing else: no host import, no host global, no DOM.
`tsconfig.build.json` is that gate, typechecking and emitting the shipped files alone, so
`node:fs`, `process` and an ES2024 method are compile errors here rather than a consumer's crash
there. The standard is the line, never an engine list: one implementing it in part — Hermes is the
live doubt, on the Unicode property escapes emphasis matching leans on and on lookbehind — is out
of scope rather than a bug. Node's test runner, the corpus reads and the build are the repo's own,
never the library's, and `engines.node` states the floor the shipped JavaScript needs — `>=18` —
never the higher one those repo-only tools want.
## ESM only
2026-08-23, the maintainer. Goal 8. Valid while the audience's toolchains all import ES modules.
No CommonJS build, no dual-package hazard.
## One built entrypoint
2026-08-23, the maintainer. Goal 8. Valid while Node refuses to type-strip under `node_modules`.
Built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint — Node refuses to
type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so it cannot serve
an npm consumer.
## Public on npm
2026-08-23, the name 2026-09-01, the maintainer. Goals 2 and 8. Valid while the package's source
stays public beside it.
Published to public npm as `@larvit/adf-codec`. Public source: the GitHub repository, LICENSE in
place. A codec, since it converts both directions, and named for the hub rather than the formats
around it.
## GitHub is canonical
2026-10-04, the maintainer. Goal 3. Valid while developers search GitHub for a library.
`github.com/larvit/adf-codec` owns the history, its issues and its CI, and publishes to npm with the
organization's `NPM_TOKEN` secret. `gitea.larvit.se/larvit/adf-codec` is a read-only pull mirror of
the GitHub repository. Its issues and Actions are off, and its description points at GitHub. A move
back to Gitea stays possible, and Gitea never holds a commit, issue or pull request of its own. Push
to GitHub only.
## The formats are API
2026-08-23, strict input 2026-09-01, content models 2026-09-16, the maintainer; what a carry
element shows 2026-10-28, the systems-architect. Goal 1. Valid while consumers store what the
library emits.
The emitted markdown and HTML are contracts. After 1.0: previously-emitted output parsing
differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR.
Pre-1.0, normal 0.x rules. A spelled node's content model is part of that contract — leaf or
container is the model, not the syntax — so giving a spelled node's model content it had not, or
taking it away, is MAJOR whatever ADF's own schema does. Input reads the canonical directive
spelling alone — spacing, key order, each value's spelling — since loosening it later is MINOR.
What an HTML carry element shows is part of that contract too. `htmlToAdf` refuses a carry element
showing anything but what `adfToHtml` shows for its node, so an edit there is refused rather than
lost (Goal 7). A change to what a carry element shows, in `src/html/carry-content.ts` or in the
`src/adf/shown-text.ts` it shares with portable markdown, makes HTML an earlier release wrote
unreadable: MAJOR after 1.0, and a `MIGRATION.md` entry before.
The error surface is a contract too; `README.md` §The errors states it to the consumer, and the
types in `src/result.ts` hold its shape.
## The code list
2026-08-25, the maintainer; dated below where a rule came later. Goal 1. Valid while a consumer
switches on `code` with no `default`.
- Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose
name reads true of it in both directions; where none does and a plain name exists, a new code —
in any 0.x minor, and after 1.0 only in a MAJOR (2026-09-18).
- A refusal whose cause is this library's own invariant rather than the input takes the existing
code nearest what the consumer sees — a document that does not convert is
`unsupported-node-shape` — since a code no input reaches is one no consumer can switch on
(2026-09-20).
- A refusal no spelling recovers from is a gap in the flavour rather than a code: give the flavour
the spelling and the code goes (`unspellable-link`, 2026-09-13; `unspellable-character` and
`unspellable-line-start`, 2026-10-04). A cause the carry answers gets no code: a mark no
spelling writes rides the carry with its node.
## Which code a cause takes
2026-08-28, the maintainer; dated below where a rule came later. Goal 1. Valid while a consumer
handles one cause alike whichever node, attribute or direction raised it.
- A code names the cause; where one cause recurs across node types, across one mark's attributes
or across directions, one code covers them all and `path` and `message` say which —
`unsupported-nesting-depth` is the 500-level guard whichever direction hits it.
- A claim code names the spelling claimed, never the node that spelling would have built: a
malformed `!adf:table` is a `malformed-directive`. An alignment colon in `losslessMarkdownToAdf`
is a `malformed-pipe-table`: the flavour's own delimiter row is `-` runs, so the grammar refuses
the colon rather than ADF's missing column model doing it. `portableMarkdownToAdf` reads GFM's
colon and drops the alignment (2026-10-14). What the grammar itself refuses stays a claim code,
key order among it, and a leaf given a body is refused at its opener, as a container missing its
closer is (2026-09-16).
- A directive whose name reads back to no node is `unknown-directive-name` rather than a claim code
— the spelling is well formed, and telling that apart from a typo is what a consumer switches on
when a later MINOR gives the name meaning. A reserved name is a known name, so never that code,
and the names the flavour reserves part on form: a form the grammar does not have is a claim code
— `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place is
`unsupported-node-shape`, `!adf:textBreak{}` parting anything but two adjacent text nodes
CommonMark would read back as one, `!adf:doc` standing beside another block (2026-09-01, the
text break and `doc` 2026-10-03).
- A well-formed directive the node tables refuse — an attribute a node does not hold or spells
elsewhere, a value outside its kind or its canonical spelling, an argument, or a body of a shape
its content model does not take — is `unsupported-node-shape`, the emitter's code for the same
mismatch read the other way: one code across both directions for good, since the call site
knows which direction it called and parting them after `0.1.0` is MAJOR (2026-09-23). A line
ending or a null character in an `emoji`, `mention` or `status` content slot is such a value; the
emitter carries such a node (2026-10-14).
- A non-finite number takes two codes: `unsupported-node-shape` parsing, `not-an-adf-document`
emitting — no document holds one, so no round-trip crosses them (2026-09-23).
- A document whose output would be longer than V8's longest string is `unsupported-node-shape`
at the document's path, in every writer, and so is a directive `losslessMarkdownToAdf` reads
whose CommonMark spelling would be: the document does not convert, and no spelling in the input
causes the bound (§The code list). A carried node passes it on about 1 MB of deeply nested JSON,
since the block carry indents two spaces a level; `todo.md` item 107 respells the carry in a
length linear in its JSON (2026-10-11).
- A conversion the engine stops with a throw of its own, past its longest array, `Map` or `Set`
or short of memory, is `engine-limit`, at the empty path. A retry may pass the memory case, so
`unsupported-node-shape`, which no retry passes, cannot hold the class. JavaScriptCore throws the
same `Out of memory` past its longest string as when its heap runs short. Nothing tells the two
apart, so both are `engine-limit` (2026-10-13, the maintainer).
- The markdown readers and `htmlToAdf` refuse a value that is not a string as `not-a-string`, at
position `{ line: 1, offset: 0 }` with an empty path: no existing code reads true of it, and
`not-an-adf-document` names the emitters' input (2026-10-05).
## `message` and `path`
2026-09-03, the path 2026-09-23, the maintainer. Goals 1 and 5. Valid while a person fixing the
input reads `message`.
- A message names the violation, not the rule alone — a rule by itself states a truth the reader
must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose
it names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and
inline alike, `\|` for every pipe row.
- `not-an-adf-document` names a node that is not an ADF node at that node's path, as
`unsupported-node-shape` does (2026-11-10, plan item 80, the maintainer). The checks of the document's own fields
and the copy `readAdfDocument` makes first, which refuses a getter, an object holding itself, a
throwing Proxy and a bad array length, stay at the empty path: the copy tracks no path, and
threading one through it wants the manual stack §Nothing recurses unbounded forces. The message
names the violation.
## Publish on a version bump
2026-08-23, converging 2026-09-03, the maintainer. Goal 8. Valid while CI on `main` holds the npm
token.
`package.json` version on `main` is the source of truth. CI on `main`: tests green and the version
not yet on npm → publish and tag `vX.Y.Z`. No bump, no deploy. `publish.sh` is that job.
The publish and the tag each check their own end state — the version on npm, the tag on the
remote — so a run that dies between them converges on the next push
to `main` rather than leaving npm ahead of the tags. An unanswered registry reads the same as an
unpublished version, which npm's own duplicate rejection is what catches. The job rebuilds rather
than taking the gate's `dist`: the lockfile is committed, the image is patch-pinned and `tsc` is
deterministic, so the two builds agree, and promoting an artifact would make the release path
depend on a store that the gate would then have to keep.
## Docs describe the release being built
2026-09-16, the maintainer. Goal 8. Valid while a bump on `main` publishes.
Docs on `main` describe the release being built rather than the version npm holds, so they match it
the moment the bump publishes; add no interim note marking the gap.
## Plan items rank by `rank_todo.py`'s goal weights, code health first
2026-10-20, the maintainer, asking that this repo follow `rank_todo.py`'s ranking after it
changed. It serves code health and the order `## Goals` gives its Achieve goals. Valid while
`rank_todo.py` sets the weights.
`rank_todo.py` is the maintainer's ranking script, kept outside this repo. The repo doesn't keep
any weights of its own. The script puts code health first, since every project pursues code health
above each achieve goal its README states. The README's Achieve goals follow in the order `## Goals`
gives them, and by position each weighs 0.97 times the one before. While the comprehension panel's
overall score is under 7.0, or `docs/comprehension-history.md` holds no scoring run, code health
weighs 4 times its position weight.
`todo.md`'s `## Scoring` keeps the formula and the bar, the lowest Score a non-exempt item may have.
Both are the project's to tune: the maintainer confirmed the bar at 0.1 on 2026-10-21.
## Posts announcing a release wait for its publish, and their drafts wait for the finished release
2026-10-21, the maintainer, asking for the choice that best reaches the goals. Serves Goal 3.
A post that goes out before its publish sends readers to a version without what the post describes.
They then open issues saying a call did something else, which Goal 3 counts as a counter-signal.
Drafts describe the finished release, so they wait for every item in it. Valid while a version bump on
`main` publishes (§Publish on a version bump).
## A chunk ruled worse merges where a plan item of its release already restores that code's reading, and that item takes the ruling's places
2026-10-30, the maintainer, merging PR #107 only because a plan item of its release would fix
exactly the places its paired ruling voted worse. Serves code health. Valid while the comprehension
check runs once per chunk.
The comprehension check runs once per chunk, after review. A restructure made after it goes
unmeasured, and past a pass's round cap it goes unreviewed. Where an item of the same release
reworks that code again, restructuring it inside the chunk does the work twice. Before the chunk
merges, that item's detail names every place the ruling voted worse.
## A chunk ruled worse that restructures the places takes a paired ruling after each restructure, against the same base, and merges once one is not worse
2026-10-31, widened to each restructure 2026-11-03, the maintainer, on PR #112: its ruling voted
Locality worse, no plan item of its release reworked that code, and a restructure after the ruling
addressed most of its places. Serves code health. Valid while the comprehension check runs once per
chunk.
Where a plan item of the release already restores that code, the entry above holds instead, and the chunk restructures nothing.
Each later ruling reads the restructured head against the first ruling's base, with the same seats,
and every ruling stays in the history. Each restructure runs one rung higher; past the top rung,
the maintainer decides. After the fifth ruling they gave the restructure to a subagent on model fable, told to look past the
current design for a better reading rather than answer the ruling's places one by one (2026-11-04). A ruling judges the code, never the behaviour: item 91's spanning stays, since the
paragraphs it spells, such as bold around a code span, occur in documents all the time
(2026-11-03). The maintainer's call on how: refactor first, ruled alone (item 166), then make spanning part of
grouping, with the span rule in one home. `inlineRuns` in `emit/inline-runs.ts` groups a line spanned or parted, `runCrosses`
says which node a run passes, and a run ends at its last holder. The writer tries the spanned line once and, where it needs any
fallback, runs its fallback loop on the parted line, so `takeFallback` takes what it is handed; whether a node is carried is
decided once per pass, on its `RunNode` (the restructure of 2026-11-04). The reducer judges the same two lines, each trimmed by
its own runs, which `runsSharedWithNext` counts beside `inlineRuns`. `withoutOpeningLinkCode`
stays on `insideRun` alone, since it answers the parted line. A refactor that merges as its own chunk took its own ruling,
so the chunk's later rulings read against that merge, the commit `larv-review ruling-base` prints. A restructure past a
pass's round cap still goes unreviewed, so the PR summary names it.
## Schema refusals are made in the first step alone
2026-11-04, replacing §No schema validation of 2026-08-23 (its mark refusal 2026-08-25) and the
entry of this date that kept the card refusal alone, the maintainer. Goal 1 and code health. Valid
while Atlassian's schema forbids shapes a converter would otherwise meet.
A shape Atlassian's schema forbids, and the library refuses, is refused in the first step, where
the document is checked once: `writableContent` opens every writer, and `carriedNode` opens every
carry reader's node. No converter refuses one. A document one call refuses on a schema rule every
writer refuses alike, with one code, message and path, wherever the shape stands, in a carried node
too, and a carry reader refuses it with the same code and the writers' message after its holder's
name. The first step refuses an `inlineCard` with a `link` mark as `unsupported-node-shape`: the
schema gives a card the `annotation` mark alone (§The node tables answer to Atlassian's schema),
and HTML nests no `<a>` in an `<a>`. The first step also refuses a known block holding `text`, a
known non-text inline node holding `text` or non-empty `content`, and a known block that takes no
content holding non-empty `content`.
Nothing else joins the first step. Nothing refuses a node type, mark or attribute the schema does
not name (§Unknown nodes ride the carry), a known node where no section spells it, or a mark a node
cannot hold (§A mark skips a node that cannot hold it), so they ride the carry. A refusal that is
no schema rule stays where it is met: a node holding one mark type twice, which the schema allows
and no spelling holds; nesting depth; output length; what a portable line can spell; and a
reader's refusal of markdown or HTML it cannot read. A schema refusal a converter gains later is
made in the first step too.
The aim is the same behaviour in and out, with the checks run at the first step and alike in every
format. Each shape the first step refuses is one some writer refuses today. The change this
makes is that every writer, and every carry reader, now refuses it: the maintainer accepts that
breaking change, since the library is not on 1.x.
The earlier entries premised that the site a document is saved to validates the rest. A
hand-built document has not reached the site yet, and the viewer/editor persona relies on a
refusal arriving before the save.
## The gate runs on Deno and Bun
2026-09-01, Deno's reason 2026-09-28, the maintainer. Goals 4 and 8. Valid while the library claims
any ES2022 engine.
The gate runs the suite under Deno and Bun as well as Node. Bun runs JavaScriptCore, the one engine
of the three that is not V8, where the Unicode property escapes emphasis matching leans on can
disagree. Deno shares Node's V8 and stays to prove the library runs there too, catching what the
two runtimes leave undocumented. Both refuse a run matching no test, so Node's is the only
vacuous-green guard, and `AGENTS.md` §3's `node:` shims rule is the price of proving those engines
over the corpus rather than over a smoke import.
## The gate installs the tarball
2026-09-03, the maintainer. Goal 8. Valid while consumers install the packed package.
The gate packs the build and installs the tarball under `package-tests/`, so `files`, `exports`
and `types` are proved on the artifact that ships rather than on the source tree a self-reference
would resolve against. `consumer.ts` typechecks the emitted `.d.ts` from outside
`tsconfig.build.json` — declaration emit leaves the `.ts` specifiers
`rewriteRelativeImportExtensions` rewrites in the JavaScript, and this is what says a consumer's
resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` stays unproven.
`node-floor.js` round-trips the installed package under a Node pinned to `engines.node`'s floor.
## Firefox reads the build
2026-09-04, the maintainer. Goal 8. Valid while the library claims a browser and no other leg runs
SpiderMonkey.
A headless Firefox loads `dist/index.js` over HTTP and converts the round-trip, normalization and
error fixtures and the real payloads — the `commonmark-spec` sort is the Node suite's to check —
which is the browser half of §Any ES2022 engine and the only SpiderMonkey there is — the gate's
other engines are two V8s and a JavaScriptCore that is not Safari's. It also sets `adfToHtml`'s HTML
into the page and reads the page's own `innerHTML` back through `htmlToAdf` (§`htmlToAdf` also
reads the dialect as a browser serializes it). A WebDriver session is what
carries a verdict back out, the driver and the page's server sharing one network namespace so each
is the other's `127.0.0.1`; `--headless --screenshot` has no such channel, and loading
`dist/index.js` in a globals-stripped realm buys one by not running a browser. The leg re-checks
the conversions and nothing else — each fixture's emitted markdown, its parsed document, its error
code — leaving the corpus's pairing, uniqueness, source positions and byte-level equality to the
Node suite that owns them. `selenium/standalone-firefox` runs it over the smaller
`instrumentisto/geckodriver`: the leg is worth a current SpiderMonkey, and that image fell four
Firefox majors behind.
## The coverage floors
2026-08-24, the maintainer. Goal 1. Valid while `noUncheckedIndexedAccess` and ADF's optional keys
force guards with a half no valid document reaches.
The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and
functions, and a branch floor that only ever moves upward. It sits below 100 because the guards
`noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index
compared against `undefined` — have a half no valid document reaches.
## The size ratchet
2026-09-20, the maintainer; the import boundaries, 2026-10-22, the maintainer. KISS, a technical
principle. Valid while no measure picks out what readers find hard better than a function's length.
`.oxlintrc.json` holds one size rule. It also holds the import boundaries §The source parts by ADF
and format draws. The size rule, over the files `tsconfig.build.json` builds, is a per-function line
ceiling, set at that set's worst and moving only downward. It covers the built files alone, since
one ceiling over the tests too would have to be their worst, loosening the guard over the shipped
code. It guards against drift and never drives a refactor, so no cyclomatic rule and no second size
rule join it: neither measure picked out what nine readers found hard (the comprehension panel,
2026-09-20). `oxlint` measures it since TypeScript 7 is a native compiler publishing no in-process
parser, only the `unstable/` AST surface an out-of-process handshake reaches. Three switches guard
a silent green: `IIFEs: true`, since oxlint exempts an IIFE otherwise; an explicit `-c`, so a
config gone missing fails the leg instead of falling back to oxlint's own defaults; and
`--deny-warnings`, since a rule from a category this config never names arrives as a warning it
exits 0 on.
## Properties on a fixed seed
2026-09-14, the maintainer. Goal 1. Valid while a red gate must reproduce.
Beside the corpus, properties run over documents generated from the node tables and over generated
markdown, on a fixed seed in the gate; a counterexample found becomes a round-trip fixture.
## The CommonMark suite checks three ways
2026-08-27, against `commonMarkToAdf` 2026-10-09, the maintainer. Goals 1 and 4. Valid while the
suite's answers are HTML ADF cannot be compared against.
Each example is a named error or markdown `commonMarkToAdf` reads to a document that
`adfToLosslessMarkdown` writes and `losslessMarkdownToAdf` reads back deep-equal, since CommonMark
has no writer of its own. The example's reference HTML's text, tags stripped and entities decoded,
equals the parsed document's; and its elements count the marks and nodes they map to. The fixpoint alone
passes a parser returning the empty document, the text alone one dropping every emphasis. An
exception is the maintainer's to add, and valid CommonMark parsing to a document
`adfToLosslessMarkdown` refuses is a bug to fix, never an exception. Kind `node-model` (2026-10-09,
the maintainer) is a permanent divergence where ADF holds no node for what CommonMark renders.
## The flavour spec is read as a source
2026-09-01, the maintainer. Goal 1. Valid while `spec/flavour.md` restates the node tables in
prose.
`spec/flavour.md` is read as a source, so the node tables cannot drift from the prose they copy:
its node and mark bullets must equal the tables in `adf/`. It guards the attributes alone: nodes
that differ in content model share a bullet, and the argument attribute is spelled outside the
bullet's attribute list, so both answer to the round-trip corpus and to nothing else where a node
has no fixture.
## The node tables answer to Atlassian's schema
2026-09-13, the maintainer. Goal 1. Valid while a site's editor writes what Atlassian's schema
holds.
For every node and mark the tables spell, the attribute names and kinds equal what `full.json` and
`stage-0.json` (§Standards ship as data) hold between them, and so do the mark types each inline
node takes. Value sets stay documentation, since any value round-trips. What the schema holds and the tables do not spell is pinned by name — an
attribute as a gap, a type as carried — so a re-pin adding either goes red until someone spells it
or pins it.
## Output past V8's longest string is refused on every engine
2026-10-11, HTML 2026-10-23, the maintainer. Goals 1 and 6. Valid while V8's longest string,
2^29 − 24 characters, is the shortest of the engines the library claims.
Every writer, and `losslessMarkdownToAdf`'s `commonMarkSpelling` ask, refuses markdown or HTML
longer than 2^29 − 24 characters (`longestOutput`), so the same document converts alike everywhere. A writer
first refuses a document whose text alone passes the bound: the lossless writer spells every
character of it, and the portable reduction refuses on the caller's text, edge whitespace and all,
since merging that text first could build past the bound. Each block, the writer's result and the
CommonMark ask's spelling are held to the bound as they are built. V8 throws building the same
string past the bound, SpiderMonkey past its own longest string, and `withinEngineLimits` refuses
both throws as the same `tooLong`, so every engine stops at the same block with the same answer. The
carry's two-space JSON, which indents every value its depth over, stops at the bound unbuilt
(`joinedWithin`).
JavaScriptCore builds a joined block up to its own longest string, 2^31 − 1, before the check. Past
that, JavaScriptCore throws `Out of memory`, which is `engine-limit` (§Which code a cause takes).
## HTML writes a URL as the ADF holds it, whatever its scheme
2026-10-23, the maintainer; kept over a scheme filter 2026-10-26. Goal 1. Valid until the maintainer
adds scheme filtering.
`adfToHtml` writes every URL exactly as the ADF holds it, escaped as every attribute value is,
`javascript:`, `vbscript:`, `file:` and `data:` included. That covers a link's `href`, an image's
`src`, a card's `href`, and the `href` of each link the carry element shows: a carried node's link mark, a card's
`url` or `data.url`, and an external image's `url`. The markdown writers don't filter schemes
either.
cmark leaves such a URL out of its HTML by default. Keeping it out of the visible HTML here, in
`data-adf` alone, was weighed and not taken: it would make the carry show a link without its
target, make `htmlToAdf` restore the URL from `data-adf`, and need a scheme list the library keeps
current. Sanitizing HTML from an author a consumer does not trust stays the consumer's, as the
README says.
## Nothing recurses unbounded
2026-08-25, the directive form's count 2026-09-18, the maintainer. Goal 1. Valid while an engine's
stack overflows near 2000 frames.
The guards walk iteratively, and blocks, marks and JSON values — an attribute's and a carried
node's alike — are all held to 500 levels (`largestNesting`), so a deep document is a `Result`
rather than the stack overflow that waits near 2000. An attribute is counted from its value; a
spelling that nests it deeper — the block directive's `marks`, the carry — refuses in its own
format, as its parser does. A list giving way to the directive form refuses at zero headroom rather
than walking again; counting every list twice halved the list limit, counting the directive form
once doubled the parser's frames per level.
## JSON text is read by `parseJsonText`
2026-10-08, a remedy the maintainer planned. Goal 1. Valid while a V8 engine with
https://issues.chromium.org/issues/521080746 unfixed can run the library.
The library reads JSON text with `parseJsonText`, never `JSON.parse`: from V8 13.6, `JSON.parse`
can read an escaped key as another key it read before, and the round-trip then refuses its own
output. A test reads escaped keys back through each reader after priming V8's misread. The tests'
own oracles keep `JSON.parse`, since a misread there fails the gate and never hides a bug.
## An ADF document is read once into a plain copy
2026-10-04, the maintainer. The 10 million limit, 2026-10-05, applying §Output past V8's longest
string is refused on every engine. The code, 2026-11-09, applying §Which code a cause takes. The
name by a developer panel, 2026-11-09. Goals 1 and 9. Valid while a persona can hand in a value with
accessors or a Proxy.
`readAdfDocument` and the three writers copy the input once, before any check, reading property
descriptors so no getter runs: an accessor property, or a Proxy trap that throws, is
`not-an-adf-document`. The copy keeps own enumerable string keys, as `JSON.stringify` does, so an
inherited or non-enumerable key drops, and reads an array by index, so no iterator runs. Every
later step reads the copy.
A cycle is an object that holds one of the objects above it on the walk's path; the copy refuses
it. The copy takes at most 10 million JSON values, each object, array, string, number, boolean and
null counting one, past which the document is `unsupported-node-shape`, since the document does
not convert. An array whose length passes the values still free is refused before any element is
read. Goal 9's size counts ADF as JSON, so a shared object costs once per place it is held. The
width check on an object's keys is the array's, and no test pins it: a Proxy would need to return
10 million key names to reach it.
The panel read 5 of 7 for `readAdfDocument` over `parseAdfDocument`, which two read as taking JSON
text, and over `validateAdfDocument`, `checkAdfDocument`, `toAdfDocument` and `adfDocumentFrom`.
## Nothing spreads an unbounded array
2026-09-18, the maintainer. Goal 1. Valid while engines cap a call's arguments.
Nothing spreads an unbounded array into a call — a node's siblings, a code block's held lines, a
mark run's segments: the argument list caps near 125k and throws a `RangeError` where a `Result` is
owed. A walk pushes one at a time. A literal spread (`[...value]`) is not the same thing and is
fine.
## A retry loop checks its own termination
2026-09-20, the maintainer. Goal 1. Valid while a fallback can fail to spell what it is handed.
A loop retrying an input until a fallback spells it refuses the pass taking no fallback, so its
termination is the loop's own check.
## After 32 passes, a line carries or drops every em, strike and strong mark
2026-10-30, the maintainer. Goals 9 and 1. Valid while a writer finds a misread run only by reading
the line back.
Each pass, the lossless and portable writers spell the line, read it back, and fall back on the
first misread emphasis run and on every run `unspellableRuns` proves falls with it: the lossless
writer carries the run's nodes, and the portable writer drops its mark. When em and strong share a
delimiter run, as in `***`, and the strong closes first, the next strong opener can pair with em's
leftover `*`. `unspellableRuns` can't foresee that pairing, so each pass falls back on one strong,
and a line repeating such a pattern 1000 times took 33 seconds lossless and 39 portable. After 32
passes, both writers fall back on every em, strike and strong run at once. Such a line keeps fewer
marks than one strong per pass would leave, and takes time linear in its length. 32 clears the
most passes any line in the suite needs, 22 portable and 5 lossless, with room to spare. The
properties' reference, which falls back on only the first misread emphasis run each pass, takes no
cap.
## With the first misread emphasis run, `unspellableRuns` returns only runs that, taken back early, leave the line as the one-run-per-pass reference writes it
2026-10-29, the maintainer. Goals 9 and 6. Valid while a writer finds a misread run only by reading
the line back.
A writer takes a run back by carrying its nodes, lossless, or by dropping its mark, portable. The
one-run-per-pass reference takes back only the first misread emphasis run each read-back pass, so
it spends a pass per run. To spend fewer, `unspellableRuns` returns, with the first misread run,
every run it proves can go back early and still leave the line as the reference writes it. A run
the proof misses costs one more pass. The properties beside `inline-line.ts` and
`inline-reduction.ts` define the reference, and check that every line the 32-pass cap (§After 32
passes) doesn't reach comes out as the reference writes it.
`unspellableRuns` reads each emphasis run as a pair, its opener and its closer, and orders the
pairs by where they close. Its sweep judges the pairs closing after the first misread pair, nearest
first, then those closing before it, nearest first, and each verdict reads the verdicts made before
it. A judged pair is settled to fall, and goes back with the first, where both hold:
- **It falls cleanly.** Under a drop, no drop may merge runs at either of its ends, so no delimiter
there moves. Under a carry, it holds no pair closing before it over fewer nodes, which the
reference may carry first, splitting it.
- **It cannot read, whichever character it is written with.** Either its opener cannot open, or
both of these hold: its closer cannot close, and it holds no pair, other than those settled to
fall, that may be written with the same character. An opener that may open can pair with such an
inner pair's closer. The reference may then take the inner pair back first, while taking the
judged pair back early may let the inner pair read as written.
Em has no fixed character: `resolveEmphasis` writes `_`, and `*` beside a word character.
CommonMark judges a delimiter by the characters either side of its whole delimiter run. So whether
a pair can read depends on the pairs whose delimiters touch its own, and a verdict tries every
character they leave possible. A verdict reads each such pair as that pair stands when the
reference reaches the judged pair. `pairStanding` returns the first of these cases that fits:
- **`stands`: the judged pair itself.**
- **`moves`: a pair at whose ends a drop may merge runs.** The merge may move its delimiters, so
the verdict can't rely on it.
- **`stands`: a pair closing after the judged one, where the judged one closes after the first.**
The reference takes back the first misread pair in closing order. Each pair settled to fall that
closes before the judged one is misread once those settled before it are gone, and the judged
pair is misread once all of them are gone. So the reference reaches the judged pair before any
pair closing after it.
- **Under a carry, `stands` for a pair over exactly the judged pair's nodes, else `moves`.** One
carry takes both back at once, and any other may be carried or split first.
- **Under a drop, `fallen` for a pair settled to fall, else `unsettled`.** The reference takes back
every pair settled to fall, so the judged pair need only fail to read once they are gone. An
`unsettled` pair may stand or have fallen, so the verdict must hold both ways.
## Readers scan by index
2026-08-30, the kept scan 2026-09-18, the maintainer. Goal 9. Valid while the pipeline persona
feeds documents nobody typed.
A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the line
before it reads, `indexOf` — never a fresh slice per character, and a per-character walk hoists the
scan that does not vary with the character: a megabyte through a quadratic walk is a minute rather
than a millisecond. A scan may keep what it read for a later walk of the same text, and the
fallback where it kept nothing must be the same reader over the same text at the same index, so the
two cannot disagree — which is what makes the kept value a memo rather than a second spelling.
## The spelling memo
2026-09-19, the maintainer. Goal 9. Valid while the `commonMarkSpelling` ask spells a node once
per level above it otherwise.
When the parse or the portable reduction asks `commonMarkSpelling` about a node, the node's readable
spelling goes into a memo, so the ask stops spelling a node once per level above it. Reading an
entry drops it, and no entry is kept for the nodes below the asked one: the walk above spells those
nodes into its own text, so keeping them would make every level of a deep subtree hold the subtree's
whole text (2026-10-11). `text`, `alternateText` and `spelling` carry no depth and `headroom` is
affine in it, so a read at or above the depth that filled the entry rebases; a read below re-spells,
because a hit skips the depth guards the walk it replaces runs and an ordered list past the marker
cap gives way, spending two emitter levels where the parser spent one. A refusal is never kept,
so no path minted at another position is ever read.
## Cost fixes are measured, never timed
2026-09-18, the maintainer and the stability-reviewer. Goal 9. Valid while Goal 9 promises growth
rather than a figure.
A cost fix that changes no behaviour lands on the suite staying green with no fixture output
changed, and a before-and-after figure in its PR; the gate times nothing. Measured and kept:
The shape walk of `readAdfDocument` and the depth walk of `writableContent` stay two — the parting
gives depth its own code — at 52 ms for a 9 MB document the emit takes 314 ms over. The 500-level guard runs after the block walk,
so it doesn't bound the walk's cost.
## Only the hard break holds a raw newline
2026-08-26, the maintainer. Goal 1. Valid while the whitespace carry finds a line edge by its raw
newline.
Only the hard break's inline segment holds a raw newline — every other spelling escapes one or
refuses it — which is how the whitespace carry finds a line edge.
## Emphasis follows CommonMark's matching
2026-08-27, the maintainer. Goals 1 and 4. Valid while CommonMark's emphasis rules are the
reader's.
Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text
escapes wherever CommonMark could open or close with it, leaving the emitter's own delimiters the
only ones in play, and a pair that matching hands to another delimiter rides the carry instead.
## Where a text backtick or `[` opens a code span, a link or an image, every text backtick or `[` in its line escapes
2026-10-28, a writer panel, 3 of 3. Goals 1, 6 and 9. Valid while the writers find a construct
by reading the escaped line back.
The panel rejected escaping only the text `[`s before the last link a text `[` opens, which
writes `[a](b) [c]` as `\[a](b) [c]`, as arbitrary. Escaping each opener in turn costs a read per
opener, against goal 9. The rule: `spec/flavour.md` §Canonical form.
## Readable spellings take the `try` prefix
2026-09-21, the maintainer. Goals 1 and 6. Valid while a readable spelling's refusal would cost a
document the general form spells.
A readable spelling tried ahead of a general one takes the `try` prefix and fails only where the
general form fails on the same node: refusing there refuses a document the general form spells, so a
refusal the general form does not share belongs in the general form or nowhere. A readable spelling
that must spell its subtree before it can give way — the list, whose thematic-break first line and
blank lines exist only spelled — hands that one walk to the general form instead: giving way after
the walk walks again at every level, doubling per level.
## The attribute vocabulary is ADF's
2026-08-27, the maintainer. Goal 2. Valid while every format spells the same ADF attributes.
`adf/` walks the attribute vocabulary and narrows each value to its kind, and a format spells the
narrowed value. A spelling that re-checks the type is the check's second copy. Reading a spelling
back is the format's own: the reader sits beside the spelling it inverts, so
decode-respell-compare cannot drift, and each format writes its own — canonical JSON for a number
is the markdown flavour's choice, not ADF's.
## The source parts by ADF and format
2026-08-27, placement 2026-09-18, `adf/`'s bar 2026-09-21, `plain/` 2026-10-03, the import
boundaries' lint 2026-10-22, the maintainer; `parse/` and `emit/` free of `portable/`,
2026-10-22; `markdown/`'s root free of `emit/`, `parse/` and `portable/`, and `emit/` free of
`parse/`, 2026-11-10. Goal 2. Valid while each format has a reader and a writer through ADF.
`src/adf/` holds ADF's own knowledge, imports no format, and is where a construct both formats read
lives: the question is answered in ADF's vocabulary — a node type, an attribute kind, a content
model — and no delimiter, element name or escape reaches it. A helper that cannot answer that way
is two constructs, the ADF question there and the spelling in each format, the seam
`markAttributes` and `markSpellings` already draw; one that cannot be split is a gap to ask.
`markdown/` and `html/` are peers: neither imports the other, and no third directory sits between
them. A primitive knowing neither ADF nor a format stays at `src/` root. A construct rises to
`adf/` on its second consumer, not in anticipation of one. A directory follows a split
`spec/flavour.md` draws, and a placement nothing here settles goes beside its only reader, or in
what both read where there are two.
A flavour's own code, both directions included, sits in its own directory: `markdown/portable/`. The
portable reader and writer hand `parse/` and `emit/` their own block readers and writers
(`FlavourBlockReaders`, `FlavourBlockWriters`), so neither imports `portable/`. Syntax that `parse/`
and `emit/` read under a `flavourClaims` claim, such as `==`, sits outside `portable/`. The
directive grammar, both directions included, sits in `markdown/directive/`, which imports neither
`emit/` nor `parse/`. Otherwise each format directory parts into `emit/` (ADF→format) and `parse/`
(format→ADF), the rest of it holding what both directions read. A construct's reader lives there
beside the regex the emitter escapes against, so the two cannot drift; a reader with no emit
counterpart goes in `parse/`, unless it is part of a construct that side already holds — a grammar
stays in one file rather than splitting across the seam. A rule both directions must answer alike —
whether a list marker interrupts a paragraph — is one function there too, never a copy per
direction, however conservative the copy would be. Where the rule is the emitter's own choice, input
consults it rather than restating it, so no fixture the emitter writes can be refused, and a
spelling the emitter refuses gives its own error rather than a second name for it. That consulting
is the only import `parse/` takes from `emit/`, through `emit/spelling-questions.ts`. The root of
`markdown/` imports none of `emit/`, `parse/` and `portable/`, and `emit/` does not import `parse/`.
`markdown/commonmark/`, a leaf the root reads, stays outside that ban: nothing in it imports them
(the maintainer, 2026-11-10). The scan in `inline-content.ts` reports whether a spellable link opens
the paragraph, and `parse/inline-content.ts` alone asks the writer whether that link still needs its
directive. A read-back (`readLineBack`) never asks the writer: the writer calls it as it spells a
line, so a read-back that asked would recurse without bound (§Nothing recurses unbounded).
`.oxlintrc.json` enforces that limit for `markdown/parse/`, keeps `html/parse/` from importing
`html/emit/`, keeps markdown's `parse/` and `emit/` from importing `portable/`, keeps `markdown/`'s
root files from importing `emit/`, `parse/` or `portable/`, keeps `emit/` from importing `parse/`,
keeps `markdown/directive/` from importing `emit/` or `parse/`, keeps `markdown/` and `html/` from
importing each other, and keeps `adf/` from importing either, as `no-restricted-imports` overrides.
A later override replaces an earlier one's rule, so each subdirectory's override repeats its
format's ban on the other format, and `ci.sh` probes every override. They are lint rules because the
technical principles refuse a test that scans the source, and a lint rule reads every import
spelling: `from`, a bare `import`, double quotes and a dynamic `import()`.
## The comparison tables list every ADF converter that starts and converts
2026-10-28, the maintainer, answering the bar for the README's "Other ADF converters" table. It
serves goal 3: a developer choosing a converter sees every one, and what it gets wrong.
A converter that starts and converts any ADF stays in its table, however poorly. Its row says where
it fails ordinary input, such as a list or a quote. A converter that doesn't start, or needs a page
rather than Node, goes in the second table. A wrapper or fork of a listed package is left out.
A converter that never reads or writes ADF is left out of both. 2026-11-09, the maintainer:
`kovetskiy/mark`, which writes Confluence storage format (`docs/kovetskiy-mark.md`), gets no row.
## The real-world corpus holds anonymized documents from sites whose licence it records
2026-11-04, on the maintainer's answers of 2026-11-03, and their answer of 2026-11-05 accepting the
inferred licence and credit by link. It serves goal 6: real documents show what ordinary input must
convert without surprise. Valid while no public source with a clearer licence
serves ADF.
Each document keeps every node, mark and attribute key. Its words, names, ids and URLs are replaced
with values drawn from fejkdata, a fake-data tool run from its image. Replaced text keeps its
punctuation and case, and a number keeps its digit count. A document stays out if its structure
changed or its output still holds an original token. The fetched originals are never committed:
`corpus-gathering/run.sh` refuses a raw folder inside the repo.
Each document's credit is its `url` in `corpus/real-world-sources.json`, a page a person reads,
since naming a creator would undo the anonymizing.
A site joins only with a published licence, its own or its host's. Where the host's terms don't name
the site, applying them is an inference, which `corpus/README.md` tells the reader. All six sites in
`corpus-gathering/sites.json` are hosted by the Linux Foundation:
- The foundation's terms of use put wikis and other non-code submissions under CC-BY-3.0. Those
terms don't name issue trackers or `*.atlassian.net`, so every site but the ONAP wiki rests on
that inference. When a site states its own licence, that licence replaces them in `sites.json`.
- The ONAP wiki states its own, but its pages disagree: a 2017 page says CC-BY-SA-4.0 and a 2019
page says CC-BY-4.0. Its documents record CC-BY-SA-4.0, which satisfies both.
None of the 22,773 fetched documents holds a type in `typesNoFetchedDocumentHolds`
(`src/conformance/corpus.test.ts`), and neither did up to 40 pages per site with "decision" in the
title. A regenerated corpus holding one of those types fails that test until the type leaves the list.
## The feature table is a section per corpus example, led by a summary table
2026-11-05, a developer panel, 3 of 3. Goal 3. Valid while the page lists about fifteen examples.
Readers from the viewer/editor, export/indexing and LLM/agent pipeline personas picked labelled code
blocks stacked under a heading per example, with a table of the round-trip results on top, over one
wide pipe table and over one wide HTML table with a `<pre>` per cell. Examples come from
`corpus/round-trip/` and are the ones `feature-table/examples.json` lists, never
`corpus/real-world/`, whose licence is not the package's.
A reader panel (2026-11-05, goal 6) judged each of the page's fifteen rows. The same three personas
first wrote what they expected of the three writers for every example, each in its own shuffled
order, then saw the page's outputs and ruled each row reasonable or not. Eleven rows drew 3 of 3
reasonable. Three drew 3 of 3 not reasonable, each because `adfToHtml` writes a node or mark it
knows as an opaque `adf-carry` element. An editor shows that element's visible copy, and an edit to
the copy is refused as `unmappable-html` rather than saved. They expected:
- A panel as `<div class="adf-panel" data-panel-type="warning"><p>…</p></div>`, with its `localId`,
colour and icon in `data-local-id`, `data-panel-color`, `data-panel-icon`, `data-panel-icon-id`
and `data-panel-icon-text`.
- A task list as `<ul class="adf-task-list" data-local-id="…">` of
`<li data-state="DONE" data-local-id="…"><input type="checkbox" checked disabled> Write the spec</li>`.
A `blockTaskItem` is an `<li>` of two `<p>`, a nested list an inner `<ul>`, and an empty item
an `<li data-state="TODO">`.
- The `strike` mark in Emphasis and strikethrough as `<s>relay</s>` or `<del>relay</del>`.
`todo.md` items 134 and 141 will write these; their writer panels pick the spelling with these
expectations as input. The Date row drew 1 of 3 not reasonable, so four more readers (bot,
reader of the markdown, export/indexing, viewer/editor) read it, and 5 of 7 found it reasonable.
`adfToLosslessMarkdown` keeps writing `!adf:date{timestamp=1756080000000}`. The two who rejected
it wanted the day shown, as `!adf:date[2025-08-25]{timestamp=…}`.