Skip to content

0.112 → 0.113 — the pdfform backend becomes acroform, the content vocabularies close, every ~~~ block is a card, reader.get answers in the values form, resolve on the reader, and the storage DTO verbs become toStored / fromStored

The pdfform backend is acroform: the crate, the backend id, the cargo feature and the pdfform::* diagnostic namespace all take the new name, and there is no alias. Every Quill.yaml declaring backend: pdfform loads clean and stops rendering with engine::backend_not_found, which no type checker reports and no test that loads without rendering reaches — so it leads the sections below.

The content vocabularies close: a stored row holding a line kind, container, mark type, island type or loss outside the built-ins stops opening. Nothing in a type checker or a compile reaches that one either — it is a fact about rows already written — so it follows.

Every column-zero ~~~ block is a card, whatever its info string. A ~~~rust fence in a document body opened an ordinary code block and now opens a card. And --- front matter no longer opens the root block: --- is CommonMark's at every position, so a document fenced that way fails with MissingQuill naming the ~~~ to write instead. These are the other two breaks a type checker cannot see, and both are about documents already written, so they follow immediately.

A Content read omits a zero Container.instance, where it spelled one on every container: the second spelling of the canonical form is gone, and what a binding hands back is now what storage holds. The value is unchanged — absent is zero — so this too is a break a type checker cannot report, only a host reading the key off a read can.

Four more changes. reader.get answers in the values form: a field whose content sits inside an array or an object reads as text rather than as stored content objects, and a present-null reads null — one more break a type checker will not report. quill.resolve(doc) moves onto the reader as reader.resolve(). And the storage DTO verbs are renamed: toJson / fromJson / loadJson become toStored / fromStored / loadStored (Python: to_stored / from_stored), and tryFromJson is deleted rather than renamed. And a Python edit::* diagnostic anchors at its DocPath, the spelling WASM already carried.

A pruning sweep lands alongside them, each break listed in its own section below: five retired Quill.yaml keys lose their tailored migration message, an implicit ui.group becomes a load error, a quill carries the load's warnings (and the two loader doors that returned them go), .quillignore stops being read and the ignore set becomes fixed, pdfform::form_schema_version retires, a vendored Typst package without typst.toml is skipped rather than given a synthesized spec, three CLI flags go, a block-only island takes its own line in the model rather than on the way out, fifteen Rust items with no caller leave quillmark-core and quillmark-content, a payload's nested comments move off its items and onto the payload, a comment indented inside a multi-line plain scalar ends it rather than folding away, the crate-compatibility ceremony — #[non_exhaustive], the Backend seal, public register_backend — is withdrawn and quillmark-core's one cargo feature goes with it, canvas capability is deleted at every layer now that a session paints by construction, pdfform drops its SVG and PNG output formats, a placed card is reached as a CardMut rather than a &mut Card, the $ext namespace verbs collapse into the whole-map three, the producer render option goes while the /Producer stamp it overrode stays, and a stamped widget carries a baked /AP — so a flat rasterizer draws its value instead of an empty box, and the second PDF the form backend flattened for its canvas is deleted. A second sweep prunes binding surface: quill.metadata loses its <backend>_<key> mirror and the CLI's info --json with it, five owner calls leave both bindings (tryFromJson, makeCard, setCardKind, renderTimeMs, formatDiagnostic, and their Python twins), a Diagnostic drops sourceChain on every surface, JS loses all eleven content guards and the foreign-handle prototype patches, a MarkOp's link / anchor payload is spelled where the decoder has always read it, and Python's CardWriter / CardReader cursors become a card= keyword on Writer / Reader.

A third group tightens refusals, each in its own section below: the §8 count caps report a count under parse::too_many_fields / parse::too_many_cards rather than a byte size, a field write past the §8 field count is refused at the write rather than at the save, MAX_YAML_DEPTH goes and the write surfaces bound a value at MAX_JSON_DEPTH (128) where they bounded it at 100, a main: block parses under the strict card-schema shape a card kind already drew, Quill::validate refuses every value the render floor refuses, a link or image url carrying a line ending is refused where it is authored, RenderOptions::pages means one thing on every backend under backend::* codes, a raster nobody can allocate is refused rather than attempted, canvas geometry measures from the page's canvas box, flatten's own parse failure is acroform::flatten_parse, a card-yaml parse failure locates on Location and drops args.line, and a Python negative index is an out-of-range index rather than an OverflowError. Not one of these is a type change: a compile and a typecheck pass clean over every one, and what moves is a code, a message, or a verdict — which is the class the ! marker exists for.

If your host never persists a Document, never calls quill.resolve, never reads a composite content-bearing field or a present-null, never reads a container's instance off a read, and never routes on a Python diagnostic's path, there is nothing to do in the first five changes. From the sweep, the two to check are the implicit ui.group, which is a load error your quill may hit today, and the CLI flags if you script them; the Rust removals reach no binding surface. From the third group, the two that change a verdict on input you already hold are main: and Quill::validate, and the one that moves a number your overlay reads is the canvas box, on an acroform background whose page box does not start at (0, 0).

The three forms

A document has three forms, one per question:

Form Definition Read Write
stored the at-rest value, verbatim, quill-free doc.getStored(addr), doc.toStored() doc.storeField(addr, v), Document.fromStored()
values stored, with every content leaf decoded to its codec's text reader.get(name) writer.set(name, v), writer.setAll(fields)
resolved values, blank-filled and render-coerced, each cell tagged with its rung reader.resolve()

A read never coerces a scalar: qty: "3" is "3" in get and 3 only in resolve(), because canonicalizing is what a write does.

Why the storage verbs move

Both surfaces carry more than one JSON view of a document: the storage DTO, lossless and frozen per schema version, and the interpreted reads, which hand content fields back as markdown for a consumer to edit. Both are JSON, so the encoding is the one thing that cannot tell them apart. toJson named the encoding.

"The verb carries the lane" already governs the write surface, and getStored already applies it to the read surface — the verbatim read is the read echo of store, distinct from the interpreted reader.get. toStored / fromStored extends that family to the document level: "stored" is the at-rest form throughout Quillmark, and it is exactly what the DTO carries.

The adjective, not the bare verb. store is one of the three field-write lanes (store verbatim / set typed / overwrite·revise·apply content), so a doc.store() / Document.load() pair would collide with a rule that is already load-bearing. toStored completes the family; store() would break it.

The old names are removed rather than aliased. An alias that keeps working while the neighbouring view means something else is the one failure neither TypeScript nor Python reports.

Migrate: rename the storage call sites

Signatures, arguments, return types, and thrown errors are unchanged.

0.112 0.113
doc.toJson() doc.toStored()
Document.fromJson(blob) Document.fromStored(blob)
doc.loadJson(blob) doc.loadStored(blob)
doc.to_json() (Python) doc.to_stored()
Document.from_json(blob) (Python) Document.from_stored(blob)
// 0.112
const blob = doc.toJson();
const restored = Document.fromJson(blob);

// 0.113
const blob = doc.toStored();
const restored = Document.fromStored(blob);
# 0.113
blob = doc.to_stored()
restored = Document.from_stored(blob)

tryFromJson / try_from_json have no 0.113 spelling: see the owner-call section.

Migrate: quill.resolve(doc)reader.resolve()

The shape is unchanged. A verb that needs a schema lives on the cursor.

// 0.112
const states = quill.resolve(doc);

// 0.113
const states = quill.reader(doc).resolve();

Breaking: pdfform is acroform — crate, backend id, feature, codes

quillmark-pdf and quillmark-pdfform differed by four characters and read as prefix-and-specialization, which invited the reading that the form backend was the spine's only consumer; the Typst backend consumes it too. The backend takes a distinct name at every layer where the old one was spelled, and the spine keeps quillmark-pdf and the pdf::* namespace untouched.

0.112 0.113
crate quillmark-pdfform quillmark-acroform
Quill.yaml backend: pdfform backend: acroform
backend section key pdfform: acroform:
quillmark cargo feature pdfform acroform
backend type PdfformBackend AcroformBackend
registered_backends() ["typst", "pdfform"] ["typst", "acroform"]
diagnostic namespace pdfform::* acroform::*
WASM EngineOptions.backends key pdfform acroform
docs page quills/pdfform-backend.md quills/acroform-backend.md

There is no alias: an unmigrated backend: pdfform loads clean and fails at render with engine::backend_not_found, whose hint lists the registered backends; a half-migrated quill — backend: acroform over a leftover pdfform: section — fails at load with quill::unknown_section.

Migrate: in every form quill's Quill.yaml, backend: pdfform becomes backend: acroform, and a pdfform: section becomes acroform:. A consumer routing on a pdfform::* code renames the prefix; the eight code suffixes (missing_form_pdf, missing_form_json, invalid_form_json, flatten_parse, flat_parse_failed, unbindable_field, dangling_binding, field_page_out_of_range) are unchanged. A Rust consumer depending on the crate by name or enabling the feature renames both. pdf::* codes, form.pdf and form.json are untouched.

Breaking: the content vocabularies close

Every discriminator inside a Content was an open set: a line kind, a container name, a mark type, an island type or loss this build did not recognize round-tripped opaque and projected as its nearest safe neighbour. All five are closed. A name outside the built-ins is refused wherever content is decoded, and a stored row holding one stops opening.

Axis Members
line kind para, heading, code, island, rule
container list_item, quote
mark type strong, emph, underline, strike, code, link, anchor
island type table, image
island loss lossless, degraded, unrepresentable

No first-party writer ever produced another name: fromMarkdown, revise and the storage writer mint built-ins only. A row is affected only if a host authored a name of its own through overwrite, applyChange or a CardInput.body and stored the result.

The mark axis is closed inside a table island's cells too, not only over marks. A cell's marks are read leniently everywhere else — a malformed one is skipped, and still is — but a name outside the vocabulary is refused at the decoder, because the lenient read is what would make it silent: normalization re-mints each cell from what it read, so a skipped name would leave the stored bytes on a mere open. 0.112 carried such a mark; 0.113 refuses the row. 0.112's isUnknownMark reads a cell's marks as well as a line's.

Door 0.112 0.113
fromStored / loadStored (from_stored) on such a row opens throws, naming the axis and the name: unknown line kind "callout"
reader.get / getContent (get_content) on such a field the value edit::field_decode, the message naming the axis and name
getStored (get_stored) / payloadItems on such a field the stored value unchanged: these echo bytes and never decode
writer.set, an insertCard body stored opaque edit::field_decode
overwrite, applyChange stored opaque throws, naming the axis and name
quill.conform silent a conform::field_decode warning, value left as authored

Migrate: before upgrading, find such rows and respell or drop the construct while a 0.112 build can still open them. 0.112's isUnknownLine / isUnknownContainer / isUnknownMark / isUnknownIsland answer which values are affected, and they go in 0.113 with the sets they classified.

One pair of surfaces answers quietly, so check them if you rely on either: getStored and payloadItems echo the stored bytes and decode nothing, so an authored-but-unconformed value carrying such a name reads back verbatim there while every decoding door refuses it. storageVersionOf still returns the tag, so a fromStored throw on a row tagged quillmark/document@0.112.0 is a content refusal rather than a version mismatch — which is what the README's storageVersionOf sniff turns on before it reaches fromMarkdown.

The storage tag is unchanged, quillmark/document@0.112.0: every byte the writer emits is the same, and only the reader's accepted domain narrowed. From here on, adding a construct is a storage-version event rather than a free pass through an older reader.

WASM types. ContentLineKind, ContentContainer, ContentMark, ContentIsland, ContentLossClass and MarkOp lose their open arm, so a bare discriminant check narrows: line.kind === 'heading' reaches line.attrs.level with no guard, and a literal like { kind: 'callout' } in a setKind or an overwrite is a type error. The four isUnknown* guards are deleted — a read never returns one and a write of one throws — which retires the last of the content guards, the seven pinned-arm ones having gone with the owner-call sweep below. MarkOp's link and anchor arms also move their payload under attrs, below.

If you carried an unknown, delete the carrier. A host that reads, edits and writes back needed one per open set. A write restates what the host holds — a setKind or setContainers carries a line's kind or path whole, an overwrite the field — so a construct its model could not hold was gone on write-back unless an inert carrier held it. No decoded content reaches one now, and what it carried for — a name from a newer build or another host — is refused at fromStored instead. The deletion reaches past the guards: the carrier's declaration in your model, both directions of your codec, and any DOM or clipboard form it crossed in.

A carrier that crossed the DOM is on the clipboard as well. A body copied from a page still running the carrier, pasted into one that has deleted it, meets the rules that remain: the construct is not rebuilt, and nothing refuses it either — the write never holds a name the table above refuses, and the row search above finds rows, not clipboards. What survives is what a paste rule keeps of markup it does not know, which is the text.

Rust. LineKind::Unknown, Container::Unknown, MarkKind::Unknown, Content::RESERVED_{LINE_KINDS,CONTAINERS,MARK_TYPES}, Invariant::ReservedUnknown{Tag,LineKind,Container}, LineKind::projects_as_para, Fidelity and Loss::fidelity are deleted. Island::island_type is an IslandType (the enum KnownIslandType was, renamed) rather than a String, and Loss is the enum Fidelity was, so Loss::LOSSLESS is Loss::Lossless. ParseError gains UnknownName { axis, name }, and LineKind::tag / Container::tag / MarkKind::tag return &'static str. An exhaustive match on any of these is a compile error, which the #[non_exhaustive] withdrawal below makes loud.

Breaking: --- front matter no longer opens the root block

A --- at document start, paired with a later ---, was accepted as a root block. It is not read as a fence any more: --- is CommonMark's everywhere in a document, a thematic break or a setext-heading underline. A document fenced that way now fails with MissingQuill.

This is a fact about documents already written, which no type checker reaches. The alias was never emitted (toMarkdown always wrote ~~~) and never documented, so it can only appear in hand- or LLM-authored markdown that has not been through a round-trip.

A document opening with 0.112 0.113
~~~~~~ root block root block
------, declaring $quill root block MissingQuill, naming the fence edit
------, no $quill MissingQuill MissingQuill
a --- below the root block composable-card error, or a break always a break

Migrate: replace the opening and closing --- of the root block with a line containing exactly ~~~. The error message says so when the block declares $quill:

Missing required root card-yaml block. Your document opens with `---` YAML
front matter; card-yaml blocks are fenced with `~~~`. Replace the opening
`---` and its closing `---` with a line containing exactly `~~~` (three
tildes).

Grep a corpus for ^---$ on line 1 to find them. Re-emitting each document through toMarkdown before upgrading also does it, since that already wrote the canonical ~~~.

One document gains a parse: a body holding two thematic breaks with a paragraph starting Word: between them — ---, Note: …, --- — was refused as a misplaced composable card, and is prose again.

Breaking: every column-zero ~~~ block is a card, whatever its info string

A ~~~ opener's info string is no longer read. ~~~card-yaml and ~~~yaml parsed as cards already; ~~~rust and every other info string opened an ordinary code block, and now opens a card too. The canonical opener is still a bare ~~~, and toMarkdown still emits that, so an info string is dropped on round-trip as the two aliases always were.

This is a fact about documents already written, which no type checker reaches. The rule the spec states — every column-zero ~~~ block with a blank line above it is card metadata — is now true without exception, and the tilde escape hatch it half-promised is gone. A backtick fence is the escape hatch.

A body containing 0.112 0.113
```rust``` code block code block
~~~rust~~~ code block a card, payload rust's content
~~~~~~ a card a card
an indented ~~~lang code block code block

Migrate: a document whose body holds a column-zero ~~~ code block with a blank line above it needs its fence changed to backticks. The failure is loud in almost every case — the code inside reaches the YAML parser and fails expected a mapping, or fails the field-name rule /^[A-Za-z_][A-Za-z0-9_]*$/. It is silent only where the block's content is a well-formed card payload, which is to say a YAML or JSON mapping whose keys are all bare identifiers. Grep your corpus for ^~~~[^ ] to find them.

Blueprints are checked for you: a body.example containing any column-zero ~~~ line is rejected at Quill.yaml parse time under quill::body_example_contains_fence, which now catches the language-tagged form it used to pass.

Breaking: a Content read omits a zero instance, and the TypeScript field is optional

Canonical content had two byte forms, differing only in whether a zero Container.instance was written. Storage omitted it, because absent is what zero means and omitting it keeps a row written before the field existed byte-identical. The bindings spelled it on every container, so the published ContentContainer type could require the field. There is one form now, and it is storage's.

The break no type checker reports. A host that reads a container and hands it straight back to a write now sends an object with no instance key. That decodes to 0, exactly as the spelled 0 did, so nothing about the document changes — but code that read the key off a read finds undefined where it found 0.

Read 0.112 0.113
getContent / getContentAt, getStored on a body, importMarkdown, rebase, doc.main.body / cards / card(i) { container: 'quote', instance: 0 } { container: 'quote' }
the same, on a run an adjacent sibling would weld with instance: 1 unchanged
getStored on a field, payloadItems already omitted unchanged
a stored blob already omitted unchanged

Writing is unchanged. Both spellings decode, a spelled 0 included, so an overwrite, a CardInput.body or a setContainers op carrying one is taken exactly as before — and assignInstances still stamps the field on everything it returns, zeros included. A writer still owes a distinct value per adjacent sibling run; that is the rule the helper carries, and it never was the type's.

TypeScript. ContentContainer.instance is instance?: number on both arms. A literal spelling it compiles, one omitting it compiles, and the as ContentContainer cast that content parsed from a stored document needed can go — that shape is now the only shape. This reverses the requirement 0.109 → 0.110 introduced.

Migrate: read the key as c.instance ?? 0 (c.get("instance", 0) in Python). A host comparing two reads compares them as it always did, both sides having moved together; one comparing a read against a value taken out of storage finds they now agree, where the key alone used to part them.

One emit behavior moves with it. toMarkdown projects a content-valued field to a markdown scalar only when the stored value is byte-identical to the canonical form, and that guard now has one form to match rather than two. A value carrying a spelled zero — a 0.112 read, written back verbatim through storeField and never re-conformed — stays a structural mapping in the emitted card-yaml instead of becoming markdown. The document round-trips either way, and quill.conform settles the value.

Rust. serial::to_seam_value is deleted. serial::to_canonical_value is the one encoder, and serial::container_to_value its container half.

Breaking: the §8 count caps report a count, not a byte size

The card-count and per-block field-count caps raised parse::input_too_large, whose one message shape is Input too large: {size} bytes (max: {max} bytes), so a block carrying 1,001 fields reported 1,001 bytes. Each cap has its own variant and code.

0.112 0.113
1,001 fields in one card-yaml block parse::input_too_large, Input too large: 1001 bytes (max: 1000 bytes) parse::too_many_fields, Too many fields in one card-yaml block: 1001 (max: 1000)
1,001 cards in a document parse::input_too_large, the same shape parse::too_many_cards, Too many cards: 1001 (max: 1000)
a document past a byte cap parse::input_too_large unchanged

The two new codes carry count and max in args, where input_too_large carries size and max. parse::input_too_large keeps the byte caps and nothing else. The caps themselves are unmoved: MAX_FIELD_COUNT and MAX_CARD_COUNT are 1000.

Migrate: a consumer routing on parse::input_too_large to mean "too big" gains two codes to route beside it, and one composing its own sentence from args.size reads args.count on those two. A consumer that never distinguished them sees only a truer message.

Migrate (Rust): ParseError::TooManyFields { count, max } and ParseError::TooManyCards { count, max } are new variants. With #[non_exhaustive] withdrawn in this same release (§ "register_backend becomes private"), a match over ParseError is exhaustive again and gains two arms rather than falling through a wildcard.

Breaking: a field write past the §8 field count is refused at the write

The card-level field cap (MAX_FIELD_COUNT, 1000) was enforced by the parser and by both storage doors, but not by the mutators. A program could write the 1,001st field through storeField / storeFill / storeFields (Rust store_field / store_fill / store_fields), through the typed set / set_all / addCard, or through revise / overwrite on an absent field, and the card accepted it — until the document was saved as Markdown, stored, or sent across the card wire, each of which refused it. Every field write now runs through one insert that holds the count, so the refusal arrives at the call that caused it.

0.112 0.113
storeField of a 1,001st field stored edit::invalid_payload, card has 1001 user fields, exceeding the maximum of 1000
overwriting a field a full card already carries stored unchanged: a replace is not a growth
storeFields naming three fields past the cap stored one diagnostic per name in the overflowing tail, nothing applied
toMarkdown / toStored on a card built past the cap refused unreachable

The code is the one the card wire already raised for this violation, so a consumer routing on edit::invalid_payload gains a reachable path to a code it already handles; the diagnostic anchors at the card that is full (main, cards.note[2]) rather than at a field, since it is the list and not the value that is over. The cap itself is unmoved.

Migrate: a program driving a schema with fewer than 1,000 fields per card cannot reach this. One that fans external data into a card — a database row with an open column set, a form with user-added keys — checks len(doc.main["payload_items"]) before the write, or catches the refusal where it already catches edit::invalid_field_name. A batch is still all-or-nothing: read the returned names to see which ones did not fit.

Breaking: a quill declaring more fields than a card carries is refused at load

MAX_FIELD_COUNT (1000) bounds one card-yaml block, and a card schema is what seeding and the blueprint build a block from. A Quill.yaml declaring 1,001 fields under main: or under one card_kinds.<name>: loaded clean and rendered any document staying within the cap. It failed only where the whole card got built: seedDocument emitted markdown the parser refuses and storage rejects, and blueprint emitted the same shape. The load refuses it now, in the artifact that declares it.

0.112 0.113
loading a quill with 1,001 fields under one card loads quill::too_many_fields, 'card_kinds.line_item' declares 1001 fields; a card-yaml block carries at most 1000
loading one with exactly 1,000 loads unchanged
seedDocument / blueprint on the over-cap quill markdown the parser refuses unreachable

The count is per card and over declared fields alone: a nested properties map, an array's items, and a variants: cell set each ride inside the one field declaring them, and main: and the card kinds are never summed. The cap itself is unmoved.

Migrate: a quill under 1,000 fields per card cannot reach this. One over it splits the fields across card kinds, or groups related ones under an object field — nested properties are one field. The diagnostic arrives from Quill::from_tree / quillmark::quill_from_path (Quill.fromTree in WASM, Quill.from_path in Python) beside every other quill::* load error, so a host already rendering diagnostics shows it with no change.

Breaking: YAML nesting depth is the parser's to bound, and MAX_YAML_DEPTH is gone

One constant set serde_saphyr's depth budget and bounded host values crossing into the document, so one number spoke for two unrelated limits. YAML parsing runs on serde_saphyr's own budget, and §8 Limits fixes no YAML nesting number. The write surfaces — store_field, store_ext, the wire and storage DTOs, both bindings' converters — bound a value at MAX_JSON_DEPTH (128), the depth storage already accepted.

0.112 0.113
quillmark_core::MAX_YAML_DEPTH 100 gone
a host value 110 containers deep through storeField / store_ext refused stored
a host value past 128 deep refused unchanged

Migrate: delete the import; quillmark_core::error::MAX_JSON_DEPTH is the bound that remains, re-exported from quillmark_content. This direction only widens — nothing that parsed or stored in 0.112 is refused in 0.113 — so a host that mirrored the constant to pre-check its own values raises its bound to 128 or drops the pre-check.

Breaking: a card-yaml parse failure locates on Location, and args drops line

parse::yaml_error_with_location carried the failing line twice: once in args.line, once inside a block-relative at line N (block K) prefix on the message. Both were the YAML engine's own coordinates, which count lines in the cleaned block rather than in the document.

The diagnostic now carries a real Location (input.md, line and column) — translated through the comment lines prescan drops and the leading whitespace trim removes, so it points at the line the author wrote — and args is {blockIndex} alone. The message names the block in words (YAML error in the root card-yaml block: …) in place of the coordinate prefix.

0.112 0.113
args keys blockIndex, line blockIndex
line's coordinate space the cleaned block
location absent input.md, the document's own line and column
message prefix at line N (block K) YAML error in the <name> card-yaml block

Migrate: read the line off location, not args — and it is a better line than the one it replaces, being the document's rather than the block's. A formatter re-wording this code from args alone finds one key where it expected two; a UI that jumped to args.line jumps to location.line.

Breaking: reader.get answers in the values form

reader.get dispatched on the field's declared type, so a field whose content sat one level in returned the stored content objects while a bare richtext field returned markdown. It now projects at every content leaf, and core's ReadValue enum is gone: get returns the plain value.

Field 0.112 0.113
array<richtext> [{text, lines, marks, islands}, …] ["Para **one**", …]
object with a richtext property {motto: {text, …}, code: "9"} {motto: "Fly **fight**", code: "9"}
variant cells the stored content objects each cell at its own codec
a present-null content field (subject:) "" null (None in Python)
richtext / plaintext whole-field markdown / literal text unchanged
a type tree with no content leaf verbatim unchanged

A leaf that does not decode still throws edit::field_decode, now anchored at the element (main.paragraphs[1]). reader.getContent and reader.getContentAt are unchanged — each answers with the Content, where get projects.

The text carries less than the object did. An anchor mark has no markdown projection, and an island's id is minted positionally by every importer, so the values form drops the one and re-mints the other. A host that read an array<richtext> element off get, edited it and wrote the array back through writer.set kept both in 0.112; the same loop in 0.113 loses every anchor in the element and re-identifies every island, and nothing reports it.

Migrate: a host reading a nested content object off reader.get to display or search takes the text it now gets. One that edits it and writes it back reads the leaf through reader.getContentAt(addr, path) (reader.get_content_at(name, path, card=…) in Python) — the 0.112 object, a zero instance now omitted — and writes the whole field back through writer.set, an untouched element re-committing byte for byte. A host that treated a present-null content field as "" reads null now.

New: a content image draws nothing, under a new warning family

![logo](assets/logo.svg) in a richtext field reaches no page. What a content image's url names is undecided — a document is quill-free but for $quill, which selects a range of versions (memo@1 admits 1.0.0 and 1.1.0 alike), and declares everything else it references — so the Typst backend lowers an image island to nothing rather than binding one reading of the string. A plate's own #image("assets/logo.svg") is unchanged: assets are the plate's to draw.

The drop is legible. Every content field holding images draws one backend::declined_construct warning on the compile, args {backend, construct, count} and the field's DocPath in path:

{ "severity": "warning", "code": "backend::declined_construct",
  "path": "main.body", "args": { "backend": "typst", "construct": "image", "count": 2 } }

It rides the session's compile warnings — session.warnings() and RenderResult.warnings — because the compile is what dropped the construct. It is the observed twin of quill-declared plate::unsupported_construct, sharing its construct / count vocabulary, and differs in the two ways that matter: it is per field rather than per body, and a backend mints it from what it saw rather than a quill from what it declared.

Migrate: a consumer routing on diagnostic codes gains a sixth warning family and needs an arm for it; one already wording plate::unsupported_construct can reuse that sentence, adding backend. An editor that offered image insertion for a Typst quill should stop, or say the image will not print.

Unaffected: storage. An image island still parses, stores, round-trips to markdown and reaches an editor with its {url, alt} props — ImageProps and the islandOps insert of a type: 'image' island are as they were. Only the page declines it, and only until content images have a design.

Breaking: a card the wire refuses raises a coded error

insertCard (insert_card) builds a card from a wire, and a violation of the card's contents used to escape without a code: err.diagnostics[0].code was undefined in JS, and Python raised a bare ValueError. Each now carries the code its addressed mutator has always minted, so the two doors onto one violation route alike.

Input 0.113 code
a field name off [A-Za-z_][A-Za-z0-9_]* edit::invalid_field_name
a value past the depth bound edit::value_too_deep
a !must_fill targeting a mapping edit::fill_on_mapping
a quill that is not name@version parse::invalid_quill_reference
a body in neither accepted encoding edit::field_decode
a duplicate key, a $ entry twice, too many fields, a comment spanning lines edit::invalid_payload

Migrate (Python): a try: … except ValueError around insert_card catches only the shape failures now — a dict that is not a card dict, a value with no JSON form. A violation of the card's contents raises QuillmarkError, like every other engine refusal; route on exc.diagnostics[0].code. Migrate (JS): nothing to change; a code appears where undefined used to be. Migrate (Rust): WireError folds its InvalidField / InvalidPayload variants into one Edit(EditError), and WireError::code() / to_diagnostic() are what a binding stamps.

Breaking (Python): an edit::* diagnostic anchors at its DocPath

Diagnostic.path carried two spellings for one refusal, so nothing could route on it: writer.set minted none, writer.set_all the bare field name. Every writer and reader verb now anchors where WASM already did — the rooted DocPath of ERROR.md § "Document-model paths".

Call 0.112 0.113
writer.set("stray", "x") None "main.stray"
writer.set_all({"stray": "x"}) "stray" "main.stray"
writer.card(0).set("stray", "x") None "cards.quotes[0].stray"
doc.set_card_kind(9, "quotes") None "cards[9]"

Migrate: a host matching d.path against a bare field name matches main.<field> now, or reads the name off d.args["field"], which carries it unescaped. writer.add_card keeps its keys: the card is committed before it joins the document, so a rejected kind, body or field is $kind / $body / the bare name, as in WASM.

Breaking: five retired Quill.yaml keys lose their tailored message

must_fill, enum, ui.order, the richtext(inline) type token and markdown were retired across 0.94, 0.104 and 0.108. Each still carried a hand-written sentence naming its replacement. All five still fail to load, under the same quill::field_parse_error code, but the message is now serde's own and there is no hint.

You wrote Write instead
must_fill: true a default: or example:, which carries the obligation
enum: [a, b] type: enum with values: [a, b]
ui: { order: N } field declaration order
type: richtext(inline) type: richtext with inline: true
type: markdown type: richtext, or richtext with inline: true

Migrate: a quill already on the current spelling is unaffected. One on an older spelling failed to load before this release and fails now — only the text changed, so a host routing on the code sees nothing new.

Breaking: main: parses under the same strict card-schema shape as a card kind

A main: that is not a mapping, an unknown key under it (feilds:, title:), and a main.fields that is not a mapping all loaded as a main card with zero fields and no diagnostic. Each is quill::invalid_card_schema.

main and card_kinds.<name> accept description, fields, ui and body only, and a malformed ui or body block under either reports quill::invalid_ui or quill::invalid_body with the hint naming that block's keys, where a card kind drew the whole-card refusal.

Quill.yaml 0.112 0.113
main: "x" loads with zero fields quill::invalid_card_schema
main: carrying feilds: loads with zero fields, the typo silent quill::invalid_card_schema naming the key
main: { fields: [..] } loads with zero fields quill::invalid_card_schema
a malformed ui: under card_kinds.note quill::invalid_card_schema for the whole card quill::invalid_ui, hinting the ui keys

Migrate: a main: block with a typo in it stops loading rather than rendering every field blank, which is the failure the silence hid — the diagnostic names the offending key, so the fix is the key. A Quill.yaml whose main: is a well-formed mapping of the four accepted keys is unaffected.

Breaking: an implicit group is a load error

A ui.group on a card with no ui.groups registry has warned since 0.94 that it "becomes an error in a future release". It is that release: quill::implicit_group is now an error, and the registry is a group's one declaration site.

Read this if your quill loads through WASM or Python. The warning never reached you. Quill::from_tree dropped the config warnings the loader collects, and only the CLI's validate took the door that kept them — so on those surfaces the error is the first notice this deprecation has given. That transport is fixed in this same release (below): from 0.113 the channel works, but it works too late to have warned you about this one.

main:
  ui:
    groups: [addressing, letterhead]   # add this
  fields:
    subject: { type: string, ui: { group: addressing } }

Migrate: declare every group id a card's fields reference. Ids are snake_case (quill::invalid_group_id); a display label goes in the group's title:. Field order within a group is unchanged, and group order now follows the registry rather than first appearance — identical for a quill whose registry lists groups in the order its fields first mention them.

Breaking: a quill carries the load's warnings, and two loader doors go

Quill::warnings() is new — quill.warnings in WASM and Python — and answers the advisory diagnostics the load collected. They ride the quill, so every construction door keeps them and a host reads them whenever it likes.

That makes the two doors that existed only to return them redundant, and both are deleted:

Gone Reach it by
Quill::from_tree_with_warnings Quill::from_tree, then quill.warnings()
quillmark::quill_from_path_with_warnings quillmark::quill_from_path, then quill.warnings()

Migrate: a caller of either takes the plain door and reads the getter; the tuple destructuring becomes one binding. A host that never wanted the warnings changes nothing. The channel's output is quill::implicit_group (an error from this release, so no longer seen here) and quill::body_example_unused, and any advisory added later arrives the same way.

Breaking: Quill::validate refuses every value the render floor refuses

Validation judged a floor refusal by the authored value's own shape, and two shapes read well-typed there. Both audited clean while compile_data and dry_run refused them, so the validate / dry_run pairing an editor runs on gave two verdicts for one value.

value 0.112 validate 0.112 render 0.113 validate
a content object that is not canonical content ({prose: …}) on a richtext or plaintext field clean refused validation::type_mismatch at the field's path
an integer past i64 on an integer field clean refused validation::type_mismatch, actual: number

A leaf the floor cannot conform is a validation::type_mismatch at the field's path, unless a shape check already names the refusal (validation::not_inline, validation::not_plain, validation::format_violation). A container's refusal stays the element's or the property's, at its own path. A numeric literal past i64 reports actual: number — the type that does carry it — and such a literal in a default: or example: is a load error rather than a validation error.

Migrate: an editor that validates and then renders gets one verdict where it got two, and the new errors are exactly the values a render already refused — there is no shape validate now rejects that dry_run accepted. A host feeding validate values it never renders should expect the render floor's verdict, which is the point of the pairing.

Breaking: .quillignore is not read, and the ignore set is fixed

A disk load no longer looks for a .quillignore at the bundle root. The file is an ordinary file now: it is walked into the tree like any other, and the rules it spells do nothing.

What a walk skips is the built-in set, which is all that is left of QuillIgnore: .git/, target/ and node_modules/ with their subtrees, anchored at the bundle root, and .gitignore wherever it sits. .quillignore itself leaves that set — the loader has no reason to hide a file it no longer reads.

QuillIgnore::new and QuillIgnore::from_content go with the format; QuillIgnore::default() and is_ignored stay, and the type is now a unit struct.

Migrate: a bundle that relied on the file to keep something out ships that something now, so move it out of the bundle directory instead — the loader has no per-bundle exclusion. A bundle whose file only restated the built-in set (the common case) loads the same tree, plus the .quillignore file itself; delete it. Rust callers constructing a rule list of their own filter the walked tree instead.

Breaking: a vendored Typst package needs its typst.toml

A packages/<dir>/ inside a quill with no typst.toml was loaded under a synthesized @local/<dir>:0.1.0. It is skipped now, with a typst::package_manifest warning naming the directory — the same code the malformed-manifest and bad-version cases already raise.

The synthesized spelling was never documented, so a quill relying on it was relying on read source. It also behaved differently from an identical package with a manifest: passing no entrypoint, it skipped the typst::package_entrypoint_missing check.

Migrate: add the file the fallback stood in for. The manifest parser defaults namespace to local, version to 0.1.0 and entrypoint to lib.typ, so this reproduces the old spec byte for byte:

# packages/<dir>/typst.toml
[package]
name = "<dir>"

Spell out version and entrypoint too if the package ships something other than a lib.typ at 0.1.0. Without the file, the plate's #import "@local/<dir>:0.1.0" fails as an unresolved file, and the load warning above says why.

Breaking: pdfform::form_schema_version retires

A form.json tagged quillmark/form@0.1.x was rejected with a pointer to the 0.93→0.94 guide. That format was retired four minors ago; the tag is now simply unrecognised.

0.112 0.113
code pdfform::form_schema_version acroform::invalid_form_json (the namespace renames in this step too)
message names the retired format and its migration guide the schema tag is not the expected quillmark/form@0.2.0

Migrate: a consumer routing on pdfform::form_schema_version loses that arm — the file still fails to load, under the code every other malformed form.json already used. Migrating the file itself is unchanged, and the 0.93→0.94 guide still describes it.

Breaking (CLI): three flags go

  • render --verbose is deleted, with its nine progress lines. --quiet no longer means "and not --verbose either": it suppresses the warning block on stderr and the Output written to: line on stdout. Errors are unaffected.
  • schema -o and blueprint -o are deleted; both commands write to stdout. quillmark schema ./quill > schema.yaml is the replacement. render -o is unchanged.
  • validate reads plate_file from the loaded quill instead of the filesystem. A plate the load excludes — an ignored path, a symlink — now fails validation, which is what rendering it already did. cli::plate_file_escapes_quill and cli::plate_file_missing stay distinct and keep their messages.

Breaking: a block-only island takes a line of its own in the model

A block-only island (a table) whose slot shared its line with prose used to be repaired on the way out: to_markdown broke the line, and the model kept the paragraph. So the model could hold a shape markdown cannot spell, and the rule lived only on the write.

Content::normalize performs the break now, and the export writes the lines it is given.

No stored document fails to load. A blob carrying the shape loads already split — exactly the content to_markdown would have written from it — with its marks rebased through the inserted newlines. What changes is when you see it: a read after load returns the split content rather than the paragraph.

Migrate: a host that reads content positions back after loading such a blob sees the split line count and rebased marks immediately rather than after the first export. An accepted LineOp::Join that runs a slot back into its prose is taken apart again by the mint. The lanes that author an island still refuse the placement up front with ApplyError::BlockIslandNotAlone.

Breaking: a line op the text or block contradicts lands and the mint settles it

setKind and setContinues refused what Content::normalize repairs anyway at the end of the same bundle. Both land now, and the mint answers:

Op Was Is
setKind: island / rule over prose throws Ok, the line is para
setKind: code on a slot-bearing line throws Ok, the line settles on what the slot spells
setContinues: true on line 0 throws Ok, the flag is cleared
setContinues: true across a container change throws Ok, the flag is cleared
setContinues: true after a heading, island or rule throws Ok, the flag is cleared

Two things a type checker cannot report. Ok no longer means the op landed as written — it means the bundle was accepted and the mint settled it, which for every row above is a value other than the one asked for. And one row costs text: a heading retagged island or rule is a paragraph afterward, its # gone from the projection, where the throw used to leave the heading standing.

Migrate: an editor that mirrored an op into its own model on Ok must read the content back after applyChangebody.lines[i].kind and .continues — or pre-check before sending: a kind against the line's text (an island line is exactly one , a rule line is empty, a code line holds no slot), and continues against the line above's containers and kind. A cleared flag is absent from the wire, not spelled false. An editor that greyed a control out on the throw needs the same pre-check to keep doing so. setKind still refuses a heading level outside 1..=6 (ApplyError::BadHeadingLevel), and the island ops still refuse a block island's slot mid-line (ApplyError::BlockIslandNotAlone).

Rust: ApplyError loses LineKindMismatch, ContinuesAcrossContainers, ContinuesSingleLineBlock and FirstLineContinues; Invariant loses those last three plus ZeroWidthFormatting, MarkEdgeOnNewline, LineKindMismatch, the four Table* shapes and BlockIslandNotAlone; LineKindMismatch and IslandType::shape_error are deleted, and model::line_kind_mismatch becomes the crate-internal predicate the mint asks. Content::validate reports what normalization cannot repair, and nothing else. The diagnostic code is unchanged — every ApplyError reaches a host as edit::content_apply — so only an exhaustive match on these enums stops compiling.

No stored document fails to load, and one that failed now loads: a blob spelling continues: true on line 0 opens with the flag cleared. Nothing first-party wrote one — import mints line 0 false and every door validated after the mint — so the rows this reaches are rows that never opened. No loadable row's bytes move.

CommonMark admits no line ending in a destination, bare or angle-wrapped, so a url of "a\nb" exported as [t](<a\nb>), which pulldown reads as an inline HTML tag: the re-import came back [t]() with the mark gone, and an image the same way with its island gone. The model could hold a link the projection dissolved.

An authored lane that stores a url refuses one, the way an unwritable code-fence lang is refused:

Door 0.112 0.113
MarkOp::Add of a link accepted, the mark lost on the next round trip refused at the op
IslandOp::Insert / IslandOp::Set of an image accepted, the island lost refused at the op
overwrite, CardInput.body accepted refused
MarkOp::Remove of a link accepted unchanged — it matches on kind equality against a mark the field already holds, so a legacy link stays removable
a stored row already holding one loads unchanged; storage stays lenient
to_markdown of such a row markup that re-imports without the mark the line ending written as %0A / %0D

Migrate: strip the line ending, or percent-encode it yourself, before the op — a url is a destination, and a destination has no line in it. Nothing on the read side changes: a row authored under 0.112 with such a url still loads, and now exports to markdown that survives its own re-import.

Breaking (Rust): fifteen items with no caller leave core and content

A pruning sweep over quillmark-core and quillmark-content. Every item below had no caller outside its own tests, and none of them is reachable from the WASM, Python or CLI surface — a host on a binding has nothing to do here.

Gone Reach it by
Document::to_plate_json nothing public. The plate JSON is what a render hands a backend; to_plate_json_gated is crate-internal, and the export was never a storage or interchange format
Document::card_kinds iterate doc.cards() and read Card::kind
impl IntoIterator for &Payload payload.iter()
MetaKey::ALL, MetaKey::is_root_only name the key: card.seed().is_some(), or match MetaKey::Seed
PathStepWire, CommentPathSegment PathSegment, which carries the untagged serde form itself
YamlError::line, column, hint err.to_diagnostic(code, file), whose location and hint carry all three
RenderedRegion::contains region.distance(page, x, y) == Some(0.0)
quillmark_core::error::print_errors the loop it was: for d in err.diagnostics() { eprintln!("{}", d.fmt_pretty()) }
Delta::apply try_apply, which returns the base-length mismatch apply panicked on
ChangeBundle::from_delta ChangeBundle { delta, ..Default::default() }
quillmark_content::normalize_markdown nothing public. import::from_markdown runs it on its input, so an importer needs no pass of its own

Two signatures move with them: normalize::normalize_document returns a Document rather than a Result it never filled, and PayloadItemWire::Field's nested_fills is Vec<Vec<PathSegment>>.

The wire is unchanged. A nested-fill path serializes as it always has, a JSON string per key and a JSON number per index, so no stored blob and no binding payload moves a byte.

Two additions come out of the same sweep: RenderError::coded_hint, the coded-plus-hint shape four backend.rs refusals built by hand, and region::nearest_region, the tolerant nearest-region search SessionHandle::field_at and the Typst backend each carried a copy of.

Migrate: the table's right column is the whole of it. A consumer matching exhaustively on PathStepWire matches PathSegment instead — the same two variants, with the same payloads.

Breaking (Rust): a quillmark-content item is named at the module that defines it

quillmark-content declared its ten modules pub and re-exported 34 of their items at the crate root, so quillmark_content::Delta and quillmark_content::delta::Delta both resolved. The root re-exports go and the modules stay public, which leaves Rust's own rule as the whole of it: an item is reachable where it is defined. MAX_NESTING_DEPTH and MAX_JSON_DEPTH keep their root spelling, the crate root being where they are defined.

Gone from the root Reach it by
Assoc, Delta, Op, diff_import delta::
to_markdown, to_plaintext export::
from_markdown, from_plaintext import::
IslandType island::
Container, Content, Invariant, Island, Line, LineKind, Loss, Mark, MarkKind, Normalized, Usv model::
ApplyError, ChangeBundle, IslandOp, LineOp, MarkOp, change_bundle_from_value, island_op_from_value, line_op_from_value, mark_op_from_value ops::
ParseError serial::
Span, items, runs, segment traverse::

Migrate: prefix the module. Every removed path is an unresolved import, so the compiler names each site. quillmark-core re-exports these names itself, so a consumer on core reaches them there — quillmark_core::Content, the session:: delta and op types, error::MAX_NESTING_DEPTH and error::MAX_JSON_DEPTH — under the core spellings the next section settles. No binding surface moves.

Breaking (Rust): a quillmark-core item is named at the module that defines it

The same shape, one crate up. quillmark-core declared its thirteen modules pub and re-exported 77 of their items at the crate root, so quillmark_core::Document and quillmark_core::document::Document both resolved. The root re-exports go and the modules stay public, which leaves Rust's own rule as the whole of it: an item is reachable where it is defined. Content and Normalized keep their root spelling: they are quillmark-content types, and the root is the only path core offers them.

Gone from the root Reach it by
Backend, DECLINED_CONSTRUCT, MAX_RASTER_PIXELS, check_raster, declined_construct, page_selection_not_supported, raster_scale, selected_pages, unsupported_format backend::
Card, CardMut, CardWire, Document, EditError, ImportError, Parsed, Payload, PayloadItem, PayloadItemWire, SeedOverlay, WireError document::
Diagnostic, Location, ParseError, RenderError, RenderResult, Severity, YamlError error::
DocPath, DocSeg path::
BoundParseError, CardSchema, FieldSchema, FieldSource, FieldType, FileTreeNode, Quill, QuillConfig, QuillIgnore, Resolved, ResolvedCard, ResolvedField, ResolvedMain, ValidationError, blank quill::
CardReader, TypedReader reader::
ContentHit, HitGranularity, RenderedRegion, doc_path_to_plate_addr, field_boxes, nearest_region, plate_addr_to_doc_path, regions_to_doc_path region::
ApplyError, Assoc, ChangeBundle, ChangeSet, Delta, IslandOp, LineOp, LiveSession, MarkOp, Op session::
Artifact, OutputFormat, RenderOptions types::
PathSegment, QuillValue, json_depth_exceeds value::
QuillReference, Version, VersionSelector, quill_ref_hint version::
CardWriter, TypedWriter writer::

quillmark::orchestration goes with them. Quillmark is named at the facade root, which is the one path the facade offers.

Migrate: prefix the module. Every removed path is an unresolved import, so the compiler names each site — 328 of them across this workspace. A consumer on the quillmark facade has nothing to do: it re-exports every name it did, now from the module-qualified core paths, and tests/facade_surface.rs holds that list. No binding surface moves.

Breaking: a comment inside a multi-line plain scalar ends it

Prescan used to delete comment lines from the YAML it hands the parser. It now leaves them in place — they are comments to the parser too — which makes the parsed string line-for-line with the fence content. One document shape changes verdict:

key: aaa
  # c
  bbb

This folded to key: "aaa bbb", a value no YAML parser reads out of that document (a comment ends a plain scalar, so bbb is a mapping line with no key). It is now a located parse::yaml_error anchored at bbb.

Every other comment position is unchanged, block scalars included: a comment line inside a literal or folded block is the scalar's own text and always was, and a comment line after one ends it without adding to it — including under keep chomping (|+ / >+), where a blank line in that position would be content.

Migrate: put the comment on its own line at the mapping's indentation, or quote the scalar (key: "aaa bbb") if the fold was what you meant.

Breaking (Rust): an enum's domain rides the type token

FieldType::Enum carries its domain, and FieldSchema::enum_values is gone:

// Before: the token said "enum", a sibling field carried the members.
FieldSchema { r#type: FieldType::Enum, enum_values: Some(vec!["CUI".into()]), .. }
if let Some(values) = &field.enum_values { /* … */ }

// After: one carrier.
FieldSchema { r#type: FieldType::Enum { values: vec!["CUI".into()] }, .. }
if let FieldType::Enum { values } = &field.r#type { /* … */ }

Migrate: match the token where you have it, and call the new FieldSchema::domain() -> &[String] where you do not — a variant-bearing branch, reached through variants:, never matched the token. domain() answers empty for every non-enum type, so read it only where the field is an enum; whether a field is one is matches!(field.r#type, FieldType::Enum { .. }). A FieldType::Enum pattern needs { .. }, and FieldType::from_str("enum") yields an empty domain that the sibling values: key fills in at FieldSchema::from_quill_value, exactly as inline: fills a prose type's payload.

Two equality notes: two FieldType::Enums with different domains are now unequal (FieldSchema equality already compared domains, so nothing changes there), and FieldType is no longer a payload-free token for any variant except the scalars.

No quill loads differently, and no stored or wire byte moves. values: is still the one spelling of a domain, still required non-empty on type: enum (quill::field_parse_error), and still a load error on any other type; the schema projection, blueprint annotation, pdfform widgets and validation all behave as before. What changes is only reachable from Rust: a hand-built FieldType::Enum { values: vec![] } is a real domain of no members, and an empty domain admits only the blank — so a non-blank value against one is an enum_violation, and pdfform binds it as a dropdown offering the blank alone. Previously such a field could not be expressed without also setting enum_values, and the three consumers that met it disagreed about what it meant.

Breaking (Rust): nested comments hang off the payload, not each item

PayloadItem::Field and PayloadItem::Meta lose their nested_comments field. The comments inside a structured value now live in one list on the Payload, at paths whose head segment names the owning entry — a field's key, or the literal $ext / $seed:

// Before: relative to the item's own value, one Vec per item.
for item in card.payload().items() {
    if let PayloadItem::Field { key, nested_comments, .. } = item {
        for nc in nested_comments { /* nc.container_path is relative to `key` */ }
    }
}

// After: one list, each path rooted at the entry that owns it.
for nc in card.payload().nested_comments() {
    // nc.container_path[0] is PathSegment::Key(<field key or "$ext"/"$seed">)
}

This is the shape both ends already spoke — prescan produces it and the storage DTO stores it — so the two converters between them are gone. Payload gains nested_comments() for reading, and rename_field, which carries a field's comments with its key; items_mut is withdrawn, having existed only for the rename that now has a verb.

The wire is unchanged. PayloadV0_92_0.nested_comments was already a flat payload-level sidecar with absolute paths, so no stored blob moves a byte, and nothing on the WASM, Python or CLI surface reads these at all.

Migrate: read from payload.nested_comments() and match the head segment instead of iterating items; rename a field with payload.rename_field(from, to).

Breaking (Rust): a placed card is reached as a CardMut, not a &mut Card

Document::main_mut returns CardMut<'_> and Document::card_mut returns Option<CardMut<'_>>; Document::cards_mut is gone. CardMut forwards every &mut self verb Card carries and Derefs to Card for the reads, so a chained call compiles unchanged:

doc.main_mut().store_field("title", "Q3")?;   // unchanged
doc.card_mut(0).unwrap().revise_body("…")?;   // unchanged

What it withholds is the one write no gate polices: assigning a whole card over a placed one. *doc.card_mut(0).unwrap() = doc.main().clone() put $quill and $seed on a composable card past push_card's placement check, and *doc.main_mut() = Card::new("note")? took $quill off the root, which Document::quill_reference reads without re-checking — a panic on the next bound call. Neither compiles now. No binding surface exposed a &mut Card, so a host on WASM or Python has nothing to do here.

Migrate: a binding that named the type (let c: &mut Card = doc.main_mut()) becomes let mut c = doc.main_mut(), mut because a CardMut is a value rather than a reference. Replacing a card wholesale is remove_card + insert_card, which runs the placement check. A slice operation over cards_mut() is move_card, remove_card and insert_card; reading every card is still cards(), and card(i) reads one.

Breaking (Rust): register_backend becomes private, and the Backend seal and #[non_exhaustive] go

Three mechanisms held the Rust crate API stable for an out-of-workspace consumer. All three go. A host on a binding has nothing to do here.

One thing stops compiling: Quillmark::register_backend is private and quillmark_core::backend::sealed is deleted, so a crate that registered its own backend cannot. That path was documented as unsupported and was already half-shut — Backend::open returns a LiveSession, which only a #[doc(hidden)] SessionHandle builds. Quillmark::new registers one backend per enabled cargo feature and is now the whole registry; Quillmark::registered_backends() still reports what it holds.

Everything else widens. No type in the workspace carries #[non_exhaustive]:

0.112 0.113
a struct literal of a pub-field type refused compiles
RenderOptions { .., ..Default::default() } refused compiles
exhaustive destructuring refused compiles
an exhaustive match over a public enum needs a _ arm compiles, and a variant added later is a compile error

Migrate: a _ arm keeps compiling wherever the arms above it leave a variant to reach. Where they leave none, rustc reports an unreachable pattern and deleting the arm is the fix — worth doing, because that arm is what a missed variant hides behind. new plus the with_* setters are unchanged and stay the shape for building a value you do not own every field of.

prose/canon/COMPATIBILITY.md goes with the attribute it governed: a SemVer promise to crates.io consumers that no CI job checked. The pub seam that exists for the workspace rather than for a consumer — Backend + SessionHandle — is described in ARCHITECTURE.md § "Backend Implementation".

Breaking (Rust): quillmark-core declares no cargo features

internal-test-seam is gone, and with it the crate's whole [features] table. It gated one method, LiveSession::update_data, which is now compiled into every build under #[doc(hidden)] — the spelling this workspace already uses for the seams that exist for a backend rather than for a consumer (SessionHandle, LiveSession::new). A host on a binding has nothing to do here.

A feature was the wrong shape for the intent. A cargo feature is public surface itself, advertised by crates.io and docs.rs, so the gate moved the opt-in from a source read to a Cargo.toml line rather than removing it. What it bought — an accidental call failing to compile — the crate already gives away next door: LiveSession::new and SessionHandle are #[doc(hidden)] pub in every build, and a session assembled through them reaches the same unchecked update.

Migrate: drop features = ["internal-test-seam"] from the quillmark-core dependency line; a dependency naming no other feature becomes quillmark-core = "0.113". update_data is unchanged — plate data straight to the backend, no $quill check and no compile — and reaching it now needs nothing.

Breaking: canvas capability goes, at every layer

Canvas is no longer something a backend has or lacks. SessionHandle's page_size_pt and render_rgba are required, so every session paints and there is nothing left to answer about — both the pre-session probe, which answered for the backend rather than for the compile, and the session's own derived answer are deleted:

Gone Reach it by
engine.supportsCanvas(quill) (WASM) engine.open(quill, doc), then paint and handle the throw
session.supportsCanvas (WASM) session.pageSize(0) / session.paint(…), which throw when there is nothing to paint
canvas in a BackendDescriptor drop the key; formats is the whole manifest now
Quillmark::supports_canvas(&quill) (Rust) session.page_count() > 0, or paint and handle the Err
quillmark_core::formats_support_canvas (Rust) session.page_count() > 0
LiveSession::supports_canvas() (Rust) session.page_count() > 0, which is what it computed

The probe keyed on output formats — true iff the backend emitted PNG or SVG — and canvas paint is a SessionHandle seam a backend overrides independently of the formats it emits, so the two could disagree. Every backend the workspace ships paints, so the probe answered true in every build.

Migrate (JS): delete the probe call and mount the canvas UI on the session you opened. paint and pageSize already throw for a compile with nothing to paint, naming the page index and the pageCount that excludes it, and that throw is now the whole contract — a consumer that probed still needed it, because a backend can compile a zero-page document. A registry entry keeps working with canvas left on it (an unknown key is ignored), but canvas is no longer required and no longer validated.

// 0.112
if (await engine.supportsCanvas(quill)) mountCanvas(await engine.open(quill, doc));

// 0.113
const session = await engine.open(quill, doc);
try { session.pageSize(0); mountCanvas(session); }
catch { showNoPreview(); }

Migrate (Rust): Quillmark::supports_canvas(&quill), quillmark_core::formats_support_canvas and LiveSession::supports_canvas() are gone. The last of the three was page_count() > 0 && page_size_pt(0).is_some(), whose second half is now always true of a counted page, so write session.page_count() > 0 where you called it — or skip the question and let page_size_pt / render_rgba answer None for the page you asked about.

Breaking: the form backend emits PDF alone

quillmark_acroform's (quillmark_pdfform before this step's rename) supported_formats reports [Pdf]. SVG and PNG were views of the flattened form, consumed by nothing but their own test, and each now fails with backend::format_not_supported — the refusal core already gave every other format outside a backend's list.

0.112 0.113
supported_formats(&pdfform_quill) [Pdf, Svg, Png] [Pdf]
render(.., format: Svg \| Png) one artifact per page backend::format_not_supported
RenderOptions::pages on a pdfform quill narrows SVG/PNG output backend::page_selection_not_supported — see the next section
quillmark render --format png on a pdfform quill a PNG per page the same refusal

Canvas paint is unchanged. render_rgba stays, the hayro dependency with it, and a WASM consumer paints pdfform pages exactly as before: paint is a SessionHandle seam, not an output format. The hayro-svg dependency goes, since the SVG artifact path was its only caller.

Migrate: a consumer rasterizing a filled form takes the canvas seam (session.paint) or renders the PDF and rasterizes it itself. A Typst quill's SVG and PNG are unchanged.

Breaking: RenderOptions::pages means one thing on every backend, under backend::* codes

The form backend ignored the option: it rendered every page whatever was asked for, and an out-of-range index passed silently. It refuses a selection now, PDF being its one format. Both backends mint the two refusals from the shared constructors in quillmark_core::backend, so the codes are backend::page_index_out_of_bounds and backend::page_selection_not_supported in place of the Typst-private typst::* pair.

0.112 0.113
pages: [99], Typst quill, SVG or PNG typst::page_index_out_of_bounds backend::page_index_out_of_bounds
pages: [0], Typst quill, PDF typst::page_selection_not_supported backend::page_selection_not_supported
pages: [0], acroform quill ignored; every page rendered backend::page_selection_not_supported
pages: [99], acroform quill ignored the same refusal
pages: None every page unchanged

Both messages carry the offending indices or the format, and the selection comes back as given: order and repeats stay the caller's.

Migrate: a consumer routing on the typst::* pair reads the backend::* pair, which is now the code for the same refusal from any backend. A host that passed pages to a form quill expecting a subset was getting the whole document and can drop the argument — the refusal says so instead of rendering something other than what was asked for.

Breaking: a raster nobody can allocate is refused, not attempted

RenderOptions.ppi and the render_rgba canvas scale reached tiny-skia and hayro unchecked. Both size their buffer from the value and unwrap it, so ppi: Infinity or 1e9 panicked the process — in WASM a trap that takes the engine instance with it — while NaN, zero and a negative quietly rasterized a 1×1 image.

Every raster path refuses, under backend::invalid_raster_scale, a scale that is not finite and positive or that would put a page past MAX_RASTER_PIXELS (16384² px: a 1 GiB RGBA buffer, and the area of the WASM painter's per-side clamp, so nothing that clamp admits is refused). US Letter at the default 144 ppi is 138× under it.

0.112 0.113
ppi: Infinity, ppi: 1e9 a process panic; in WASM the engine instance traps backend::invalid_raster_scale
ppi: NaN, 0, a negative a 1×1 image the same refusal
render(pages=[-1]) (Python) OverflowError see § "a negative index is an out-of-range index"
ppi: 144 on US Letter a raster unchanged

Migrate (Rust): SessionHandle::render_rgba and LiveSession::render_rgba return Result<Option<(u32, u32, Vec<u8>)>, RenderError> to carry the refusal; Ok(None) is still the out-of-range page, so a caller that matched the Option keeps that arm and gains the Result.

Migrate (hosts): a UI handing a user-typed zoom or DPI straight to ppi gets a diagnostic where it got a blank image or, at the top of the range, a dead engine. Nothing that rendered a real page in 0.112 is refused.

Breaking: canvas geometry measures from the page's canvas box

hayro rasterizes /CropBox/MediaBox and draws that box's lower-left corner at the raster's origin, while page_size_pt reported the /MediaBox extent and regions() reported widget /Rects in raw user space. An overlay over a pdfcropped background (/MediaBox [96 133 500 700]) therefore sat 96 × 133 pt off its ink, and a /CropBox inside the MediaBox reported a page bigger than its own raster.

page_size_pt is the canvas box's extent, regions() subtracts its lower-left corner, and form.json's top-left rects flip against it, so pageSize, regions, the point queries and the raster share one origin.

background 0.112 0.113
a page box starting at (0, 0) correct unchanged — most quills are here
/MediaBox [96 133 500 700] pageSize 500 × 700; regions 96 × 133 pt off the ink pageSize 404 × 567; regions on the ink
a /CropBox inside the /MediaBox a page bigger than its raster the raster's own extent
a canvas box under a point per side a degenerate raster pdf::degenerate_page_box

The stamped PDF's widget /Rects stay in user space. Canvas geometry is box-relative; the deliverable is not.

Migrate: a host that corrected for the offset itself — subtracting a background's /MediaBox origin before painting an overlay — deletes that correction, or it now double-counts. A host on backgrounds that start at (0, 0) sees no change at all. quillmark_pdf::page_media_boxes is page_canvas_boxes, and it refuses a page box that is not a direct array of numbers.

Breaking: a stamped widget carries its own /AP, and the flatten path is gone

A stamped widget carried /NeedAppearances and no appearance stream, so a viewer that synthesizes appearances showed the value and everything else — a raster pipeline, Ghostscript, a thumbnailer — drew an empty box. The acroform backend worked around that for its canvas by building a second PDF with each value baked into the page content streams, and previewing that instead.

Every widget now carries a baked /AP /N Form XObject drawing its own value, so one document serves both: the stamped PDF renders its values wherever it is opened, and the second one goes with the code that built it.

0.112 0.113
a stamped widget's /AP absent one Form XObject per drawn value
a /DR text face's /Encoding absent /WinAnsiEncoding
the acroform canvas raster a flattened second PDF the stamped PDF itself
a page dict or /Contents flatten could not read acroform::flatten_parse gone with the path
the raster's document failing to parse acroform::flat_parse_failed acroform::stamped_parse_failed
a widget /Rect that is not four finite numbers pdf::bad_rect unchanged

Read this if you rasterize a Quillmark-stamped PDF. Pages that came back with blank fields now come back filled — a change in rendered pixels that no type checker reports. A pipeline that composited values in itself will draw them twice; drop that step. Both backends are affected: a Typst form-field carrying a value: bakes one too.

The baked stream is an approximation and /V remains the source of truth: it draws WinAnsi (a code point outside CP1252 draws as ?), from the box's left edge whatever /Q says, clipped to the box. /NeedAppearances still rides the form, so a synthesizing viewer rebuilds every appearance from /V and /DA and none of the three limits reaches it.

Migrate: nothing to change to keep the interactive PDF working — /V, /DA, /Q and the widget geometry are untouched. A consumer routing on acroform::flatten_parse deletes that arm and one on acroform::flat_parse_failed reads acroform::stamped_parse_failed. A Rust caller reaching into quillmark_pdf::reader or quillmark_pdf::writer, or naming ObjectIndex or PdfUpdate, has no replacement: both modules were #[doc(hidden)] and are now private, and the crate's surface is stamp, regions_of, page_canvas_boxes, FieldSpec and the checkbox constants.

Breaking (Typst): form-field keeps text and signature

The checkbox and choice kinds go from the Typst helper. Interactive non-text widgets are the form backend's: a plate wanting a fillable checkbox or dropdown is an acroform quill. text keeps multiline, and signature-field is unchanged.

0.112 0.113
form-field(.., type: "checkbox") a /Btn widget a Typst assert naming the two types
form-field(.., type: "choice", options: (..)) a /Ch widget the same assert; options: is not a parameter
font / size / align on a "choice" field applied

Migrate: a plate placing a checkbox or dropdown moves that form to an acroform quill, or draws the box in Typst and drops the widget. A plate using only text and signature is unaffected.

Breaking: the $ext namespace verbs collapse into the whole-map three

$ext is a map the engine never inspects, keyed by a namespace its consumer owns. Six verbs addressed it on WASM — whole-map and namespace variants of store, remove and read — where three do, because a namespace write is a spread on the client and the map's read shape is its write shape.

Gone Reach it by
doc.storeExtNamespace(addr, ns, v) doc.storeExt(addr, {...doc.getExt(addr), [ns]: v})
doc.removeExtNamespace(addr, ns) spread without ns, then storeExt — or removeExt when nothing else is left
doc.getExtNamespace(addr, ns) doc.getExt(addr)?.[ns]
doc.store_ext_namespace(ns, v, card=i) (Python) doc.store_ext({**(doc.cards[i]["ext"] or {}), ns: v}, card=i)
doc.remove_ext_namespace(ns, card=i) (Python) the same merge without ns, or remove_ext(card=i)
Card::store_ext_namespace / remove_ext_namespace (Rust) card.ext() cloned, edited, card.store_ext(map)

getExt / storeExt / removeExt and Python's store_ext / remove_ext are unchanged, card selector included, and so is what $ext holds: stored bytes, the Markdown round trip and the plate strip are untouched.

One wrinkle the spread does not carry. removeExtNamespace dropped the $ext entry entirely once its last namespace went, where storeExt({}) records an explicit $ext: {}. A consumer emptying the map calls removeExt instead:

// 0.113 — remove one namespace, and the map with it when it was the last
const { [ns]: gone, ...rest } = doc.getExt(addr) ?? {}
Object.keys(rest).length ? doc.storeExt(addr, rest) : doc.removeExt(addr)

Migrate (Rust): the two Card verbs had no caller outside their own tests once the bindings stopped delegating to them. Card::ext reads the map and Card::store_ext writes it, both bounded by the same depth check the merge used, so a namespaced write is a clone, an insert and a store.

Breaking: the producer render option goes, the stamp stays

RenderOptions.producer overrode the PDF /Info /Producer string on every surface it was plumbed through. It goes from all of them. What a rendered PDF carries is unchanged: Quillmark <version>, stamped on every PDF render as before.

0.112 0.113
render(.., { producer: 'ACME' }) (WASM) stamps ACME the key is undeclared; typecheck reports it, and the value is ignored
render(.., producer="ACME") (Python) stamps ACME TypeError: unexpected keyword
RenderOptions::with_producer(..) (Rust) stamps the string gone
a rendered PDF's /Info /Producer Quillmark <version> unchanged

The default moves down to the spine that writes it. StampOptions::producer is a String whose Default is Quillmark <version>, in place of an Option<String> each backend filled from its own default_producer() — two copies of one format! over the same version.workspace = true. So quillmark_pdf::stamp always appends its /Info revision, where a None producer over an empty field list returned the base bytes untouched; both backends always passed a producer, so no render changes. StampOptions::with_producer goes with the Option, and a caller wanting another string writes the field — StampOptions { producer: "ACME".into() } — which the #[non_exhaustive] withdrawal above makes legal again.

Migrate: delete the argument. A host that must name itself in the PDF owns the /Info rewrite on the returned bytes, which is where it always belonged: no first-party caller set this, and the engine carried the string through four crates and two bindings to reach a writer the host can reach directly.

Breaking: Quill.metadata loses the <backend>_<key> mirror

A loaded quill carried a flat map holding four Quill.yaml identity fields verbatim plus every key under the backend section flattened as <backend>_<key>, so typst: { plate_file: plate.typ } surfaced as a typst_plate_file entry. Nothing read a mirrored key: the Typst backend and the CLI read QuillConfig::backend_config, and both bindings built their identity keys from the config and used the map only for the leftovers.

quill.metadata (JS and Python) now carries exactly its five identity keys — name, version, backend, author, description, in that order — and nothing else. In Rust, Quill::metadata and quillmark_core::STANDARD_METADATA_KEYS are deleted.

Migrate: read a backend setting from the config section it was authored in.

// 0.112
const plate = quill.metadata.typst_plate_file;

// 0.113 — the key is under the backend section, where the backend reads it
// 0.113 (Rust)
let plate = quill.config().backend_config.get("plate_file").and_then(|v| v.as_str());

A host that needs a backend setting on the JS or Python side reads Quill.yaml out of quill.toTree() / quill.to_tree(): no binding surfaces backend_config, and none did before except through this mirror.

Breaking (CLI): quillmark info loses --json

The flag's one distinctive output was that metadata mirror. Its text output is unchanged but for the Metadata: section going with it, and validate -v prints the same name/backend/field/card/defaults counts if you were scripting info --json for them.

Breaking: five owner calls leave both bindings

Each is a call the host can make itself, in one or two lines, from surface that stays. They go from both WASM and Python, so WASM remains the reference surface Python mirrors.

0.112 (JS / Python) 0.113
Document.tryFromJson(b) / try_from_json Document.storageVersionOf(b) ? Document.fromStored(b) : null
Document.makeCard(k, f, b) / make_card a CardInput object literal (below)
doc.setCardKind(i, k) / set_card_kind removeCard + insertCard at the same index with the new kind
result.renderTimeMs / render_time_ms clock the render call yourself
Document.formatDiagnostic(d) the CLI and Python's str(diagnostic) still render it

makeCard built a string-bodied wire and round-tripped it because CardInput refuses a flat field map. The literal is that wire:

// 0.112
doc.insertCard(Document.makeCard("note", { x: 1 }, "Body."));

// 0.113
doc.insertCard({
  kind: "note",
  payloadItems: [{ type: "field", key: "x", value: 1 }],
  body: "Body.",
});
# 0.113
doc.insert_card({
    "kind": "note",
    "payload_items": [{"type": "field", "key": "x", "value": 1}],
    "body": "Body.",
})

Validation is unchanged: it always ran at insertCard, which is the kind gate and refuses a bad field name or an over-deep value under the same edit::* codes.

Timing: performance.now() in JS, time.perf_counter() in Python, around the render call. Rust never carried it.

Breaking: a Diagnostic drops sourceChain / source_chain

The field leaves all three surfaces — Rust Diagnostic::source_chain, JS Diagnostic.sourceChain, Python Diagnostic.source_chain — together with the Rust builder that filled it, Diagnostic::with_source.

One code in the engine ever filled it: typst::world_creation, raised when the Typst compilation environment cannot be built. Its cause is a message with no cause of its own, so the chain was a one-element array holding exactly the text message already ends with. Every other diagnostic serialized without the field, which was omitted when empty.

0.112 0.113
diag.sourceChain[0] / diag.source_chain[0] diag.message, which interpolates the same cause
Diagnostic::with_source(&e) (Rust) interpolate the cause: RenderError::coded(code, format!("...: {e}"))

Nothing printed it. fmt_pretty covers severity, message, code, location and hint, so the CLI's output and Python's str(diagnostic) are byte-identical.

Breaking (JS): a MarkOp spells its payload in attrs

MarkOp's link and anchor arms declared their payload as a named sibling, { op: 'add', start, end, type: 'link', url }. That is the spelling the authored lane refuses as the retired @0.93.0 encoding — the decoder reads a built-in's payload out of attrs, and applyChange answers the sibling with invalid markOps: content json shape: legacy mark payload. So the type named the shape the runtime rejects.

0.112 0.113
{ op: 'add', start, end, type: 'link', url } { op: 'add', start, end, type: 'link', attrs: { url } }
{ op: 'add', start, end, type: 'anchor', id } { op: 'add', start, end, type: 'anchor', attrs: { id } }

{ op: 'removeAnchor', id } is unchanged: that arm is an op with an id, not a mark payload.

Nothing on the wire moves. The runtime accepted attrs and only attrs throughout 0.112, so no working call changes and no stored blob does. What changes is which calls compile. A consumer whose op type-checked did so through 0.112's open arm, { type: string; attrs: unknown }, which took the attrs shape the runtime wants; the arm goes with the vocabularies (above), so without this correction there would be no spelling that both compiles and runs.

The payload has a name. ContentMarkKind is the three arms, exported from the package root beside ContentLineKind; ContentMark is a range over it and a MarkOp's add / remove is a ContentMark under an op. Both resolve to the shapes above, so nothing you wrote changes, and an arm added to the mark vocabulary reaches the op with no second declaration to lag behind it. A held mark spreads into an op whole — { op: 'remove', ...mark } type-checks for any ContentMark — and a payload built without one types as ContentMarkKind, where 0.112 had no name for it: Omit<ContentMark, 'start' | 'end'> collapses the attrs arms, since Omit does not distribute.

Migrate: a MarkOp written against the declared arms was already throwing — move its payload into attrs. One written against the open arm compiles now without its cast: as MarkOp on a correct op can go.

Breaking (JS): the eleven content guards go

isTableIsland, isImageIsland, isLinkMark, isAnchorMark, isHeadingLine, isCodeLine and isListItemContainer are deleted. Each was one x.type === 'k'. The four isUnknown* guards go with the vocabularies they classified (see § "the content vocabularies close"), which also makes the discriminant check narrow on its own: no cast needed.

Migrate: switch on the discriminant.

// 0.112
if (isTableIsland(island)) render(island.props);

// 0.113
if (island.type === 'table') render(island.props);

Breaking (JS): a foreign handle on a by-reference method is a bare Error

The prototype patches on Document.equals, Quill.validate and Quill.conform are gone, with the Symbol.for('@quillmark/wasm:handle-checked') marker they needed. wasm-bindgen's own _assertClass already refuses a foreign class wherever a method declares a reference parameter; it throws a bare Error, so isQuillmarkError reads false there now.

The seams that cross as data keep their check, because nothing else would catch them: the four writer/reader binds, every Engine verb, and LiveSession.update. Those throw in contract as before, except that runtime::foreign_handle retires: a value that is not one of this copy's handles — the wrong type, or the right class from a second copy — is now runtime::not_a_quill / runtime::not_a_document, whose hint names both cures including npm ls @quillmark/wasm.

Migrate: a host routing on runtime::foreign_handle routes on the two not_a_* codes instead. A duplicate install still fails loudly at every seam that could hide one.

Breaking (Python): CardWriter / CardReader become a card= keyword

writer.card(i) and reader.card(i) returned a cursor holding the index. In core the writer holds a &mut Document; in pyo3 it held two Py<> and re-borrowed per call, so the cursor was a namespace, not a guarantee — and it carried a hazard the docs warned about twice: a remove_card between binding and writing silently retargeted it.

Every Writer and Reader verb now takes card, the selector Document already used for remove_field and store_ext: None (the default) is the main card, an int the composable card at that index, read at the call.

0.112 0.113
writer.card(i).set(n, v) writer.set(n, v, card=i)
writer.card(i).set_all(f) writer.set_all(f, card=i)
writer.card(i).revise_body(md) writer.revise_body(md, card=i)
writer.card(i).revise_field(n, t) writer.revise_field(n, t, card=i)
reader.card(i).get(n) reader.get(n, card=i)
reader.card(i).get_content(n) reader.get_content(n, card=i)
reader.card(i).get_content_at(n, p) reader.get_content_at(n, p, card=i)
reader.card(i).body_markdown() reader.body_markdown(card=i)
reader.card(i).kind / writer.card(i).kind doc.card(i)["kind"]
reader.card(i).index / writer.card(i).index the i you passed

Diagnostics are unchanged: a refusal still anchors at cards.<kind>[<i>], and an index addressing no card still raises edit::index_out_of_range. The JS cursors are untouched — writer.card(i) / reader.card(i) stay there, where the writer holds the document by reference and the cursor is a real binding.

Breaking (Python): a negative index is an out-of-range index, not an OverflowError

Every index parameter was a usize, so doc.card(-1), doc.move_card(-1, 0) and render(pages=[-1]) died in pyo3's boundary conversion with an exception type the binding does not document, carrying no diagnostics to route on. Indices are signed at the boundary now. A negative one addresses nothing — it is not the last card, and the binding does not index from the end — so it takes the answer its own site already gives an index past the end.

call 0.112 0.113
doc.card(-1), doc.move_card(-1, 0), writer.set_all(.., card=-1) OverflowError edit::index_out_of_range, as doc.card(99) gives
render(quill, doc, pages=[-1]) OverflowError backend::page_index_out_of_bounds
doc.remove_card(-1) OverflowError None, as remove_card(99) gives
any non-negative index unchanged unchanged

Migrate: an except OverflowError around a card or page call catches nothing now — the refusal arrives as QuillmarkError carrying its diagnostic, the way every other refusal on these calls already arrived. Code reaching for -1 to mean the last card never worked (it raised), so read len(doc.cards) and pass len - 1.

Unaffected

  • Stored blobs. The wire format, the schema tag (quillmark/document@0.112.0), and byte-determinism within a version are untouched. A row the 0.112 writer produced loads in 0.113 and back. A row a host authored a vocabulary name of its own into does not — see § "the content vocabularies close".
  • storageVersionOf / currentStorageVersion (and their Python twins) keep their names: they already say storage.
  • reader.getContentAt / get_content_at, the Content read of a leaf nested inside a composite field: the one read that carries the leaf's anchors and island ids, where get now projects to text — see § "reader.get answers in the values form". Python's spelling takes the card= selector like every other Reader verb.
  • Core (Rust). Document serializes through #[serde(into / try_from)] and callers use serde_json directly, so there is no verb to rename. The collision was a WASM/pyo3 artifact of fusing the DTO onto the one Document handle, as getStored's was. Quill::resolve stays as the core producer beside TypedReader::resolve.
  • toMarkdown / fromMarkdown, equals, clone, and every mutator. fromMarkdown reads one shape differently — see § "every column-zero ~~~ block is a card".
  • quill.validate / quill.conform / quill.parse, which stay on the quill as its ingestion and verdict verbs. validate's verdict moves on two shapes — see § "Quill::validate refuses every value the render floor refuses".