Skip to content

0.99 → 0.100 — card $id is removed, the content model closes, a content field reads its own codec, load conforms it to one resting form, and a diagnostic carries its facts

$id is gone from the document model: the reserved key, its resolver, its uniqueness contract, and its projection on both bindings. Nothing in the engine read it. It never reached a backend — the render seam strips every $-prefixed entry before a plate is built — and the one documented consumer, patch-and- re-render, hand-scanned cards rather than calling the resolver. What is left after removing the machinery that existed only to serve the resolver is $ext with a reserved name, so $ext is the slot from here on.

The break is a hard cutover on both carriers. A card-yaml block declaring $id no longer parses, and a stored blob carrying an id payload item no longer loads. Neither shape exists in any known corpus, which is why the frozen @0.92.0 / @0.93.0 DTO trees drop the variant outright instead of migrating it: the schema version is unchanged, and every blob that does not carry a $id loads and re-serializes byte-identically.

Break Surface Action
$id leaves the closed $-key set Markdown (all surfaces) Delete the line, or move the value into $ext under your own namespace
id payload item removed from the storage DTO Storage (all surfaces) Nothing, unless a stored blob carries one — then rewrite it before upgrading
Document::find_card removed Rust Scan cards() for your own $ext key
Document::set_card_id / remove_card_id removed Rust Card::store_ext_namespace / remove_ext_namespace
Card::id and Payload::{id, set_id, take_id} removed Rust Card::ext / store_ext_namespace
EditError::CardIdCollision / EmptyCardId removed Rust Delete the arms; #[non_exhaustive] already required a _
PayloadItem::Id removed Rust Delete the arm
CardWire.id removed Rust Read your key out of CardWire.ext
doc.cardIndexById(id) / doc.card_index_by_id(id) removed TypeScript, Python Scan doc.cards for your own $ext key
Card.id / CardInput.id removed from the card shape TypeScript, Python Read and write ext instead; passing id now throws
parse::card_id_empty / parse::card_id_duplicate warnings retired All surfaces Drop any routing on those codes

Move the value into $ext

$ext is an opaque mapping that round-trips through Markdown and the storage DTO and never reaches a backend, namespaced per consumer so two tools carrying state on one card do not collide. It has been the documented slot for per-card consumer state since 0.91, and both bindings can already write it.

# 0.99
~~~
$kind: line_item
$id: row-4718
qty: 3
~~~

# 0.100
~~~
$kind: line_item
$ext:
  myapp:
    row_id: row-4718
qty: 3
~~~
# 0.99
idx = doc.card_index_by_id(row_id)

# 0.100 — `ext` is `None` on a card carrying none, so the lookup guards
idx = next(i for i, c in enumerate(doc.cards)
           if (c["ext"] or {}).get("myapp", {}).get("row_id") == row_id)
// 0.99
const idx = doc.cardIndexById(rowId)

// 0.100
const idx = doc.cards.findIndex((c) => c.ext?.myapp?.row_id === rowId)

Write the key with store_ext_namespace (storeExtNamespace in JavaScript), which touches only your namespace and leaves another tool's slot alone:

doc.store_ext_namespace("myapp", {"row_id": row_id}, card=index)

What you no longer get

$id carried three guarantees that $ext does not, and they go with it.

One name every tool agrees on. $ext fragments by construction: an editor and an MCP writer namespace separately and cannot find each other's key. Two tools that must share a handle now have to agree on a namespace out of band.

At-most-one resolution. $id was unique per document, enforced at every write boundary. A duplicated $ext key resolves to whichever card the scan hits first, silently.

Convergence on a hand-edited file. Parse repaired a duplicate $id under a warning, so a merge-conflicted markdown file loaded and one emit converged on a valid document. $ext has no repair pass: a merge conflict that duplicates a key stays duplicated.

If your consumer needs any of the three, it needs a card identity the engine does not provide, and the layer that owns the document tree is where to put it.

$id is now an unknown key

There is no tolerate-and-ignore period. A block carrying $id fails the same way any other unrecognized $ key does:

Unknown `$id` system-metadata key: the card-yaml block accepts only
`$quill`, `$kind`, `$ext`, and `$seed`

The unsigiled id is unaffected: it was always an ordinary user field, and a schema declaring id keeps working unchanged.

A plaintext field reads through the literal codec

reader.get on a plaintext field whose value rests as a string decoded it as markdown, so note: 'a *literal* line' read back as a literal line — the asterisks parsed as emphasis and dropped — while render and validation decoded the same string literally and kept them. The read now uses the codec the declared type names, and agrees with the render.

Only the string form was affected. A plaintext field the typed writer committed rests as a content object and always decoded correctly, which is why the split went unnoticed: the two lanes disagreed, and only one had coverage.

Surface Before After
reader.get(name) on a string-valued plaintext field markdown-imported (marks interpreted, then stripped) literal (marks are characters)
Card::field_plaintext same, via field_richtext same fix; Card::field_plaintext_content is the new corpus door

If you compensated for the old behavior — pre-escaping asterisks or underscores into a plaintext field so they survived the read — remove the escaping. The stored value was always the literal text; only the read was wrong.

reader.getContent: the corpus, whichever lane built the document

A content field's stored form depends on how the document was constructed. The typed writer commits a canonical corpus; a markdown parse leaves the authored string, and coercion only reconciles the two at render. Both are intended — a hand-written card-yaml file is meant to stay as authored — but it left no read that spanned them, so a consumer holding a corpus editor branched on the wire shape itself:

// 0.99 — every consumer re-derived the engine's decode
const stored = doc.getStored(addr)
const content = typeof stored === 'string' ? importMarkdown(stored) : stored

// 0.100
const content = quill.reader(doc).getContent(addr)

That branch is wrong for a plaintext field (its strings are literal, not markdown) and drifts from the engine at the edges ("", null, and how a failure surfaces). getContent decodes through the codec the field's declared type names, which is why it lives on the schema-bound reader and not beside the quill-free getStored: the same stored bytes decode two ways, and only the declared type says which.

Surface Verb
Rust TypedReader::get_content(name) / CardReader::get_content(name)Option<Content>
TypeScript reader.getContent(addr) / reader.card(i).getContent(name)Content \| undefined
Python reader.get_content(name) / reader.card(i).get_content(name)dict \| None

Absent fields read back undefined / None. An undeclared name raises edit::unknown_field and an undecodable value edit::field_richtext_decode, as get does. A declared type that is not a content leaf raises the new edit::field_not_content — which codec decodes a value is a property of the declared type, not of the stored shape, so an integer field has no corpus even when it holds a string, and an array<richtext> carries content without having one. On the WASM binding an absent addr.field reads the body corpus, mirroring getStored.

No wire shape changes, and getStored still reports the stored value verbatim; its doc no longer claims a content field always reads back as an object. What the stored value is does move, in the same release: see the next section.

EditError gains a variant. It is #[non_exhaustive], so a match already carrying a _ arm compiles unchanged; route on edit::field_not_content if you want to name it.

Conform-on-load: content fields have one resting form

A content field used to rest in whichever shape its construction lane left: the typed writer committed a canonical corpus, a markdown parse left the authored string, and coercion reconciled the two only at render. The divergence was observable wherever the payload is. equals and content hashes separated semantically identical documents; a parse-then-commit with no semantic change moved the stored bytes; and getStored answered "corpus or string?" with "depends how this document was built".

The invariant. A content field rests in its codec's lossless written form whenever the quill resolved, the value commits under the strict write, and no !must_fill marker rides anywhere in it. Every departure is a named state carrying a marker or a diagnostic, never a silent second resting form.

Codec Rest Why
richtext the canonical content object the markdown projection is lossy (anchors, island ids, content-only marks), so string rest loses identity
plaintext the literal string from_plaintext / to_plaintext are inverses on plain content, so string rest loses nothing — and object rest corrupts at emit (below)

The bound door. Quill::conform(&mut doc) is the primitive and Quill::parse(md) (parse, then conform) the convenience, now the documented primary ingestion path. Both live on the quill because both need the schema.

# 0.99
doc = Document.from_markdown(md)          # rests as authored

# 0.100
doc = quill.parse(md)                     # rests canonical; doc.warnings carries conform::*
// A document that arrived any other way converges in place.
const doc = Document.fromJson(row)
const diags = quill.conform(doc)          // [] when everything rested

Document.fromMarkdown / Document::parse stay exactly as they were, demoted to the transport/repair door: migrations, $ext stamping, a quill that will not load, and opening a document to fix its $quill. The resting form of what they return is unspecified. Conform is idempotent and a byte no-op on an already-canonical document (YAML comments included), so calling it on every load is safe.

Four states, none silent:

State Result
No quill available Loads through the transport door, readable and round-trippable, resting as authored
Wrong quill conform / parse error before any mutation (quill::name_mismatch / quill::version_mismatch)
Value the strict write refuses Rests as authored plus a conform::* warning; the document opens for repair and renders under the existing render-floor rules
!must_fill anywhere in the value Rests as authored; the marker is the state

What changes for a consumer

  • quill.parse(md)to_markdown canonicalizes richtext fields. Authored markdown re-emits as the export projection (__b__**b**), the same way a body always has. Document.fromMarkdownto_markdown stays verbatim.
  • getStored changes JS-visible type in both directions: content objects where authored strings used to rest, and strings where typed-writer plaintext objects used to rest. Read through reader.get / reader.getContent if you do not want to care.
  • Stored rows converge, once. A row read through the bound door and re-stored moves its hash — read-repair, not a schema-version event. A consumer gating recompiles on equals sees one extra recompile per row the first time. Once a population has converged, byte-stability is unconditional for content fields instead of conditional on construction lane.
  • The typed writer commits plaintext as a string. commit_field / set / set_all on a plaintext field now store the literal text. The plate does not move: the render floor still coerces it to the content object backends receive.
  • writer.revise_field on a plaintext field uses the literal codec. It used to decode the current value as markdown, so a byte-identical revise of a \*b\* line silently committed a *b* line. It now diffs literal text, and a no-change revise is a byte no-op. The schema-blind Card::revise_field / apply_field_richtext_change / install_field are richtext verbs by contract; a plaintext field written through one is a departure the next bound load converges.
  • Blueprint ingestion moves to the bound door. A consumer that fills a blueprint and submits the markdown (the MCP create_document flow) should parse it with quill.parse so the stored document is at rest from birth.

Legacy data: repair before you export

Ordering matters for one window. Emit is schema-free: it projects every canonical content object it finds through the markdown exporter, and it cannot tell a plaintext content from a richtext one. A plaintext field resting as an object therefore emits markdown-escapeda *literal* line becomes a \*literal\* line — and a re-parse reads the backslashes as characters.

  • Stored rows: load → conform → re-store, before any markdown export. Once a row is conformed the field rests as a string and emit passes it through untouched.
  • Markdown already exported from a typed-writer plaintext field under ≤0.99 is corrupt at rest. The escapes are in the file and are indistinguishable from authored ones, so nothing can recover the original text mechanically. Audit the markdown exported from documents whose plaintext fields were written through the typed writer; re-export them from the stored rows after conforming.

Documents whose plaintext fields only ever arrived through a markdown parse were always stored as strings and are unaffected.

The content model takes #[non_exhaustive]

Content, Line, Mark, and Island were the four public structs the 0.99 sweep missed: the pass ran as two issues split by crate, and quillmark-content had only its enums covered. They carry the attribute now, on the same terms as the rest of the API, so a struct literal outside quillmark-content gives way to new plus the with_* setters. Nothing about the wire, the canonical bytes, or the stored form moves: this is a Rust source break only, and the bindings are untouched.

Break Surface Action
Content { .. } literal Rust Content::new(text, lines), then .with_marks(…) / .with_islands(…)
Line { .. } literal Rust Line::new(kind), then .with_containers(…) / .with_continues(true)
Mark { .. } literal Rust Mark::new(start, end, kind)
Island { .. } literal Rust Island::new(id, island_type), then .with_props(…) / .with_loss(…)

Every field stays pub, so reading and assigning are unchanged; only the literal and an exhaustive destructuring are forbidden. A consumer normally builds through from_markdown / from_canonical_json / the op channels, which are unaffected.

// 0.99
let rt = Content {
    text: "abcdef".to_string(),
    lines: vec![Line { kind: LineKind::Para, containers: vec![], continues: false }],
    marks: vec![Mark { start: 0, end: 4, kind: MarkKind::Strong }],
    islands: vec![],
};

// 0.100
let rt = Content::new("abcdef".to_string(), vec![Line::new(LineKind::Para)])
    .with_marks(vec![Mark::new(0, 4, MarkKind::Strong)]);

Each new takes what the value always carries and every optional field starts absent, so the defaults are the wire's own reading of an omitted key: a Line with no containers that starts a new block, and an Island with Null props whose loss is the faithful class. Set anything else with the setter.

Delta, Segment, and BaseLengthMismatch stay open deliberately, and now say so in their rustdoc — Delta because {ops} is the serde wire, where a second field is a wire change regardless of the attribute, and the other two because they are small derived shapes with nothing to grow. For those three a new field would be a semver-major.

Diagnostic.args: the facts the message interpolates

Nothing breaks; the field is additive on a #[non_exhaustive] type, absent from the wire when empty, and every existing consumer keeps reading message.

A diagnostic's code routes and its message reads, but a consumer wording the failure in its own language had only the English sentence to mine. args carries what that sentence interpolates, keyed by name, so code + args is the substitution unit:

const d = quill.validate(doc)[0]
// d.code === 'validation::enum_violation'
// d.args === { value: 'loud', allowed: ['quiet', 'firm'] }
strings[d.code]?.(d.args) ?? d.message

Values keep their JSON shape — allowed arrives as a list, len as a number — because joining and pluralizing are locale decisions. Engine prose never rides under a key: where a message bottoms out in text minted per-site (a coercion reason, another codec's error), that text stays in message and contributes no arg, so a consumer's own sentence is coarser than ours rather than half-translated. A formatter whose template needs an absent key renders message whole, and takes hint from the engine in the same breath.

Surface Access
Rust Diagnostic::argsBTreeMap<String, serde_json::Value>; Diagnostic::with_args
TypeScript diagnostic.args?: Record<string, unknown>
Python diagnostic.argsdict

prose/canon/ERROR.md § "Diagnostic args" tabulates the keys per code and states the growth rule: per code, keys are append-only and never retyped, and value spellings are as frozen as the keys. validation::*, edit::*, conform::*, and parse::* are covered; quill::* (quill authoring) and typst::* (an open set spelled by Typst's own message text) carry none, so a template falls back on them by the rule above. A test fails when the code and the canon table disagree.