Skip to content

0.111 → 0.112 — one spelling per name: every payload rides attrs

The canonical content had two ways to spell a construct's payload. A built-in put it in named siblings ({"kind":"heading","level":1}); an unknown put it in one opaque attrs bag ({"kind":"callout","attrs":{…}}). Which one a document used depended on whether the build that wrote it happened to know the name — so promoting a name from unknown to built-in changed the encoding, and five mechanisms existed to bridge the two forms.

Now there is one:

{ "kind": "heading", "attrs": { "level": 1 }, "containers": [] }

Heading{level}attrs.level, Code{lang}attrs.lang, Link{url}attrs.url, Anchor{id}attrs.id, and ListItem's ordered/start/ ordinal ⇄ the same three under attrs. Envelope keys stay siblings — kind/type/container, containers, continues, a mark's start/end, a container's instance — because they belong to the object, not to the member it names. An empty bag is omitted. The Rust types do not change: still typed in memory, now uniform on the wire.

Stored documents keep loading, and you do not have to migrate them. The decoder reads the old spelling wherever it meets it. That is not a courtesy: it is the only thing that can work, for reasons in Why the decoder, not a migration below.

What breaks

Reading content JSON at the seam. Every host that reads line.level, line.lang, mark.url, mark.id, or a list item's ordered/start/ordinal off a Content gets undefined. The values are one level down, under attrs:

// 0.111                          0.112
line.level                        line.attrs.level
line.lang                         line.attrs?.lang
mark.url                          mark.attrs.url
mark.id                           mark.attrs.id
container.ordered                 container.attrs.ordered
container.instance                container.instance   // unchanged: envelope

The TypeScript narrowing types move with them, so npm run typecheck reports this break rather than leaving it to run time. isHeadingLine(line) now narrows to { kind: 'heading'; attrs: { level: number } }, and its four siblings the same way.

Writing content JSON, including the op wire. The authored lane rejects the old spelling instead of quietly reading it: {"type":"link","url":"u"} in an overwrite, or in a markOps / setKind / setContainers entry, is a shape error. This is deliberate. The storage decoder would read that sibling, so accepting it at the write door would let a host keep writing a spelling nobody else emits, and where a bag sits beside it the sibling is not the one that wins. A CardInput.body is not among those doors: the card wire decodes storage-lane, so a card read from one document pushes into another whatever spelling it carries. Respell your writes:

// 0.111
doc.applyChange({}, { markOps: [{ op: 'add', start: 0, end: 5, type: 'anchor', id: 'c1' }] })
// 0.112
doc.applyChange({}, { markOps: [{ op: 'add', start: 0, end: 5, type: 'anchor', attrs: { id: 'c1' } }] })

A typed field write — a content object handed to a field through the metadata patch, or a richtext literal in a Quill.yaml — is deliberately not one of these doors: it decodes storage-lane and re-canonicalizes, so the old spelling lands correctly there instead of throwing. Respell it anyway; nothing else emits it.

A foreign bag on a built-in is now legal, where 0.111 refused it. attrs beside a known name is no longer an ambiguous shape, so {"kind":"para","attrs": {"tone":"warn"}} is accepted and the bag drops unread — the same bound as before ("the carrier preserves unknown tags, not unknown payloads on known tags"), restated per attrs key.

Bare content JSON has no dispatcher. A consumer holding {text, lines, marks, islands} outside a stored document — a cache, a queue payload, a fixture — carries no schema tag, so nothing tells it which spelling it holds. Reading it back through this library is fine (the decoder takes either). Anything that picks the JSON apart itself needs the change above.

Rust: MarkKind::ord is gone, replaced by MarkKind::sort_key. tag() and attrs() are new on LineKind, Container, and MarkKind.

What moves without breaking

Canonical bytes. attrs appears on built-ins, and coincident marks reorder within equal (start, end) because the tie-break is now the (type, attrs) pair the wire carries rather than a hand-assigned ordinal. Mark order carries no meaning, but it is hash input: content hashes change, so cache keys and template-divergence checks keyed on stored bytes recompute once.

Exported markdown. exportMarkdown / to_markdown nests coincident marks in that same order, so its bytes move too: **~~x~~** becomes ~~**x**~~. The two parse back to the same content, and re-importing either gives the same canonical form — but a host that diffs or hashes the projection sees a change, on the same one-time basis as a content hash.

Generated Typst. Coincident wraps nest in the stored mark order — #emph[#strong[x]] where 0.111 wrote #strong[#emph[x]]. Every wrap is a content-wrapping call, so the two set identical glyphs under default styling; only the generated string moves. Golden expectations over emitted markup need regenerating. The one place the nesting is visible is a quill whose show strong: / show emph: rule decorates its body — a box, a fill, a stroke — where the two orders paint the decoration inside out from each other.

The storage tag is now quillmark/document@0.112.0 (Document.currentStorageVersion). Rows tagged @0.93.0 migrate forward on read; nothing else about the envelope changed.

Why the decoder, not a migration

A migration can only reach what it can find, and it can only find the card body. Every other stored content sits inside an opaque payload value with no schema tag over it:

  • a richtext field rests as the canonical content object — that is its documented resting form, and it is where most stored content lives;
  • a plaintext field, an array<richtext> element, a content property on an object field, the same;
  • a $seed overlay carries field values by card kind, the same;
  • $ext is arbitrary host data by contract, so a walk that rewrote something in it that merely looked like content would be corrupting a consumer's payload.

So there is no walk that finds all the stored content and nothing else. Had the decoder been made new-spelling-only, every one of those values would have mis-read: a hard error for heading and anchor, and a silent one for code's lang, link's url, and a list item's numbering, which have defaults.

The fallback is therefore in the decoder, and it is small: when the attrs bag is absent, read the named sibling. It is unambiguous, because a built-in carrying a payload always writes a bag, so an absent bag means either the old spelling or an empty payload — and those two agree on every key. Unlike the fold it replaces, it is frozen: it does not grow when a name is promoted, because promotion no longer changes the encoding.

It is also not retirable on the usual evidence. A legacy DTO retires when no row carries its tag; these values carry no tag, so "no rows remain in that shape" is not checkable. Read-repair converges the population — a row read and written back rests in the current spelling — and cold rows keep the fallback alive.

Checklist

  • Respell every seam read of level / lang / url / id / ordered / start / ordinal to go through attrs. npm run typecheck finds these.
  • Respell every seam write of the same, op wire included. These throw — except a typed field write, which reads the old spelling and repairs it.
  • Recompute anything keyed on a content hash, on stored document bytes, or on the bytes of an exportMarkdown projection.
  • Regenerate goldens over emitted Typst markup, and over exported markdown.
  • Leave your stored documents alone.