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:
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_markdowncanonicalizes richtext fields. Authored markdown re-emits as the export projection (__b__→**b**), the same way a body always has.Document.fromMarkdown→to_markdownstays verbatim.getStoredchanges JS-visible type in both directions: content objects where authored strings used to rest, and strings where typed-writerplaintextobjects used to rest. Read throughreader.get/reader.getContentif 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
equalssees 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
plaintextas a string.commit_field/set/set_allon aplaintextfield now store the literal text. The plate does not move: the render floor still coerces it to the content object backends receive. writer.revise_fieldon aplaintextfield uses the literal codec. It used to decode the current value as markdown, so a byte-identical revise ofa \*b\* linesilently committeda *b* line. It now diffs literal text, and a no-change revise is a byte no-op. The schema-blindCard::revise_field/apply_field_richtext_change/install_fieldare richtext verbs by contract; aplaintextfield 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_documentflow) should parse it withquill.parseso 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-escaped — a *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
plaintextfield 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 whoseplaintextfields 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::args → BTreeMap<String, serde_json::Value>; Diagnostic::with_args |
| TypeScript | diagnostic.args?: Record<string, unknown> |
| Python | diagnostic.args → dict |
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.