@quillmark/wasm 0.133.0 → 0.134.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,349 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.134.0 - 2026-11-02
4
+
5
+ Upgrade path: [0.133 → 0.134](docs/migrations/0.133-to-0.134.md). Every
6
+ content value is the content model, and offsets in content values and changes
7
+ cross WASM in UTF-16 code units, where they counted scalar values. Python's
8
+ `revise_body`, `revise_field` and `revise_document` return a dict receipt,
9
+ where they returned a list of `Diagnostic`, so a loop over the result runs
10
+ over its keys: read `receipt["warnings"]`.
11
+
12
+ ### Content v2
13
+
14
+ - feat(content)!: **the content model.** One value per body or `richtext`
15
+ field: flat text, blocks and containers with ids, marks, anchors apart from
16
+ marks, and islands whose cells and notes are slots of nested content.
17
+ Nothing derived is stored: an item's number is computed, and runs are told
18
+ apart by container id. The line form moves under `quillmark_content::lines`
19
+ and stays what the renderers and markdown codecs read; `migrate` moves
20
+ values between the two, minting ids deterministically.
21
+ - feat(content)!: **the line form's op lane is removed.** `lines::ops`
22
+ (`ChangeBundle`, `LineOp`, `MarkOp`, `IslandOp` and their decoders),
23
+ `Card::apply_body_change`, `Card::apply_field_change`, the `Normalized`
24
+ `apply_*` methods and `EditError::ContentApply` go: a `Change` through
25
+ `Card::apply` is the one edit. `quillmark::session` no longer re-exports
26
+ them. The parity corpus's op-wire cell reads the `Change` `diff` takes
27
+ from the empty value.
28
+ - feat(content): **`Change`, the one way a value is edited.** Splice,
29
+ `setBlock`, `container`, `mark`, `anchor`, `island` and `nested` steps,
30
+ with `invert`, `compose`, `map_pos` and a word-aligned `diff`, each a
31
+ property-tested law. A change yields a valid value or fails and changes
32
+ nothing.
33
+ - feat(content)!: **a block's record rides the `\n` that opens it.** A
34
+ split names the right half and the left keeps its id, so Enter at the end
35
+ of any block, the last included, leaves the text on its id; a join keeps
36
+ the left block's id and kind. `blocks[0]` is the record no splice reaches,
37
+ so `diff` and `replace` keep the stored first id, and `repair` gives the
38
+ first piece of a block it splits beside an island the block's record.
39
+ - feat(content): **`gestures::split_block` and `join_blocks`** build Enter
40
+ and Backspace as one splice each, inserting ahead of a block's opening
41
+ `\n` at its start so the text keeps its id.
42
+ - feat(content): **`gestures::inherited_marks(content, at)`** answers the
43
+ marks typed text carries: `strong`, `emph`, `underline` and `strike` grow
44
+ at their end, `code`, `link` and an `x-` mark at neither edge,
45
+ `font_family` at both.
46
+ - feat(bindings)!: **every WASM offset is UTF-16.** A region's `span`,
47
+ `positionAt`'s and `locate`'s `pos`, a typed revise's `Revised.delta` and
48
+ the deltas `reattach` takes count in UTF-16 code units, as content values
49
+ and changes do; core takes a host's units through `LiveSession::*_in`,
50
+ `Engine::render_in`, `Revised::delta_in` and `Document::reattach_in`.
51
+ - feat(content): **`repair` makes a merged state valid.** A missing
52
+ container leaves a path, a container under two paths takes its first
53
+ block's, a later run gets a derived container, an orphan slot or record
54
+ goes, text beside a table splits off, and a collapsed range anchor is
55
+ dropped. Total and deterministic.
56
+ - feat(content): **repair is how a merged state reads, and a write-back is
57
+ optional.** Peers holding one merged state compute one value without
58
+ writing. Any peer may write back a repair that only deletes or sets; one
59
+ that inserts (a block split beside an island, a heading's hard break, a
60
+ missing cell) is written back by a single writer, since two peers would
61
+ each insert their own. `Repair::inserts` says which, `inserts: true` on
62
+ the wire, and a missing cell reports `Repair::EmptyCell`.
63
+ - feat(core)!: **storage moves to `quillmark/document@0.134.0`**, each body
64
+ and richtext field the content model. An older row loads migrated, with the
65
+ same ids on every load. A value holds each anchor id once over its text
66
+ and every slot, so a stored value holding one twice loads keeping its
67
+ first holder.
68
+ - feat(core)!: **`Card::apply`, `revise` and `replace`** write a body or
69
+ richtext field and answer a `Receipt`: the applied change, the new hash,
70
+ and the dropped and moved anchors. Each takes the hash its writer read and
71
+ refuses a stale one with `edit::stale_base`. `Card::write` takes the
72
+ `Edit` each builds its change from, and `write_in` takes a host's units.
73
+ - feat(wasm)!: **`doc.read`, `apply`, `revise`, `replace`, `annotated` and
74
+ `hash(addr)`, and `invert` and `diff`**, offsets in UTF-16 code units.
75
+ Every content payload crossing the binding is the model. `overwrite`,
76
+ `reviseContent`, `applyChange` and `mapMarks` are removed: call `replace`
77
+ and `apply`. `compose`, `rebase` and `repair` stay unbound until the
78
+ editor or collaboration calls them.
79
+ - feat(wasm)!: **the editing gestures and `mapPos(change, pos, assoc?)`.**
80
+ `splitBlock`, `joinBlocks`, `deleteRange`, `indent`, `outdent`, `wrap` and
81
+ `inheritedMarks` take a `Content` and answer the `Change` to apply, offsets
82
+ in UTF-16. `mapPos` carries a position across a `Change`, where it took a
83
+ line delta, and answers a `MapResult`.
84
+ - feat(python)!: **the same surface in snake case**, offsets in code points.
85
+ - feat(core)!: **a content write names its card by `$id`.** An id no card
86
+ holds refuses with `edit::unknown_card_id`. `Document::card_index` and
87
+ `card_by_id` find a card by id. WASM `Addr.card` and Python `card=`
88
+ take a card's `$id` on every verb, and `apply`, `revise` and
89
+ `replace` take nothing else, so a card moved or inserted between a read and
90
+ its write, its body equal to the one read, no longer takes the write.
91
+ - feat(content)!: **the receipt names every anchor a change loses.** An
92
+ island a splice deletes, or a slot a props step no longer names, names the
93
+ anchors of its slots, each with the `island` and `slot` that held it.
94
+ `revise` and `replace` name every anchor they lose; `apply` every one but
95
+ those its own `anchor` steps removed. `Applied.dropped` and
96
+ `Receipt.dropped_anchors` are `change::Dropped`, an `AnchorContext` and a
97
+ `SlotRef`; WASM spells it `DroppedAnchor`.
98
+ - feat(content)!: **`diff` refuses an invalid target.** `diff` and `replace`
99
+ return the `Invariant` an invalid `after` breaks, where they returned zero
100
+ steps, and reach every valid one step-wise. WASM and Python `diff` answer
101
+ the `Change`.
102
+ - feat(content)!: **every offset-addressed step can hold to what its writer
103
+ read.** `mark` steps and an `anchor` placement take an optional `quote`,
104
+ the text their range covered, and an `island` insert an optional `block`,
105
+ the id of the block it lands in; a mismatch is `ApplyError::Stale`, as a
106
+ splice's `delete` is. `Applied.applied` and `invert` fill both in.
107
+ `MarkStep::Add`, `MarkStep::Remove`, `AnchorStep::Set` and
108
+ `IslandStep::Insert` carry the new fields.
109
+ - feat(content)!: **`map_pos` answers a `MapResult`.** A position always
110
+ lands, inside deleted text on the deletion's `assoc` edge and inside an
111
+ island a splice deleted on the island's slot, with `deleted`,
112
+ `deleted_before`, `deleted_after` and `deleted_across` saying what went
113
+ around it. A splice that deletes an island and names none in `removed` is
114
+ refused, where it mapped a position inside that island as kept. New
115
+ `mapping::StepMap` and `Mapping`, a list of step maps with mirror links, so
116
+ a position a step deleted and its rebased mirror put back maps home.
117
+ - feat(content)!: **reserved `x-` names on three vocabularies.** A block
118
+ kind, mark type or island type spelled `x-` and more decodes into
119
+ `BlockKind::Ext`, `MarkKind::Ext` or `IslandType::Ext`, its attrs or props
120
+ held as written, and re-encodes to the same bytes; any other unknown name
121
+ still refuses. Validation is structural only. An `x-` block reads as a
122
+ paragraph, an `x-` mark formats nothing, and an `x-` island renders and
123
+ exports to markdown as nothing, which the annotated read spells as its
124
+ token so a revise keeps it; a content write's revise or replace that
125
+ removes one refuses with `edit::drops_ext_island`, and `apply` removes it.
126
+ Steps set and remove them. `MarkStep::Remove`'s `tag` is a `String`,
127
+ `BlockKind::tag` and `MarkKind::tag` return `&str`, and `IslandType` is no
128
+ longer `Copy`.
129
+ - feat(content): **`rebase(base, change, over)` moves a change past another
130
+ made from the same value**, after ProseMirror's `rebaseSteps`. Each step
131
+ maps through the inverse of its change's earlier steps, then `over`, then
132
+ those steps rebased, and holds to its `delete`, `quote` or `block` on the
133
+ value it now reads; a step `over` deleted the text of, or whose id is gone,
134
+ is a `rebase::Conflict` naming it. A step by id that would overwrite what
135
+ `over` changed (a block's kind or path, a container, an island's props)
136
+ conflicts rather than undoing it, as does a splice that deletes an island
137
+ or block `over` changed, so a diff's `setBlock` names only the fields it
138
+ changes.
139
+ `Mapping::map_point` and `project` map
140
+ as `apply` moves a point anchor and inside one slot.
141
+ - feat(core)!: **a stale `base` rebases the write instead of refusing it.**
142
+ Each card keeps, in memory only, a log of its recent writes per value: one
143
+ snapshot of the value, and each write's normalized change and the hash it
144
+ left. A base rebases while the writes since it hold at most `LOG_STEPS`
145
+ (4096) steps, thousands of keystrokes, and the base the newest write read
146
+ whatever its size; past twice that, the oldest writes fold into the
147
+ snapshot, so the log holds one copy of the value however large.
148
+ `apply`, `revise` and `replace` with a base the log holds rebase over the
149
+ changes since, and a revise or replace diffs from the value the base
150
+ names. The receipt's new `rebased` says so. A conflict refuses with
151
+ `edit::rebase_conflict`, carrying `step` and `reason`; a base the log does
152
+ not reach is `edit::stale_base` as before. `Card::read_at` answers the
153
+ value a base names, and `BaseError::Conflict` carries `step` and
154
+ `reason`.
155
+ - fix(content): **`replace` moves an anchor between the prose and a cell**,
156
+ where it refused with `DuplicateId`: it carries only an anchor the new
157
+ value names nowhere. A props step naming a slot in `slots` that the props
158
+ do not name says so, apart from one naming a slot the island holds.
159
+ - feat(content)!: **a block or island keeps its id when a change moves
160
+ it.** Block and island ids are unique at the end of a change, as anchor
161
+ ids are, so a step may insert a copy before another deletes the original;
162
+ a step naming an id held twice between steps is `ApplyError::BadStep`.
163
+ `diff` keeps the id of a block or island moved either way, where it
164
+ re-minted one moved toward the front.
165
+ - perf(content): **`apply` runs a change in one pass.** Each step works on
166
+ the blocks it touches, checks only what it touched, and answers its
167
+ inverse as it applies; the whole value is validated once, at the end.
168
+ `invert` reads those inverses back, and a receipt lowers the value or
169
+ compares anchors only where something was dropped or rewritten. A
170
+ 4000-step change to 288k characters applies in 26 ms, where it took 96 s,
171
+ and inverts in 30 ms, where it took 496 s; a revise of it takes 0.1 s and
172
+ a rebase 0.2 s, where they took 294 s and 396 s. An inverse's mark steps
173
+ restore each run of marks that differs, not one range over all of them.
174
+ `tests/speed.rs` is the gate, run in CI in a release build.
175
+ - perf(content): **converting a change or value to UTF-16 finds each
176
+ offset by binary search**, where it walked the text from its start: the
177
+ 4000-step change crosses each way in about 20 ms, where it took 12 s out
178
+ and 19 s in.
179
+ - perf(core): **`content_hash` digests the content value as written**,
180
+ whose keys are already in order, rather than sorting a copy; the bytes are
181
+ the same.
182
+
183
+ ### Card identity
184
+
185
+ - feat(core)!: **every composable card carries a `$id`.** It emits after
186
+ `$kind`, is minted where none is written (`c` and five base32 letters of a
187
+ fixed SHA-256 sequence, so equal markdown parses to equal ids), and a parse
188
+ re-mints a malformed or repeated one with `parse::card_id_reminted`. A
189
+ null `$id` (`$id:`, `~`) is no `$id`.
190
+ `insert_card` refuses a bad or held id (`edit::invalid_card_id`,
191
+ `edit::duplicate_card_id`). An older row mints its ids on load. `$id`
192
+ never reaches the plate JSON.
193
+ - feat(core)!: **a whole-document revise pairs cards by `$id` first.** An
194
+ incoming card spelling a stored card's id revises it, the most similar of
195
+ several spelling one keeps it, and the rest align by text as before. A
196
+ revised card keeps its stored id; an inserted one keeps a fresh id or mints
197
+ past every stored one. A list field's items align by text, so an item
198
+ inserted ahead of an anchored one leaves the anchor on its own item.
199
+ - feat(core)!: **a comment selector names its card.** `AnchorSelector` and
200
+ `DocumentAnchor` gain `card_id`, and `reattach` resolves a selector's card by
201
+ it wherever the card moved. WASM spells it `cardId` on `AnchorContext`,
202
+ `DocumentAnchor` and the `Card` / `CardInput` `id`; Python `card_id` and the
203
+ card dict's `id`.
204
+
205
+ ### Two-way annotated read
206
+
207
+ - feat(content)!: **a revise reads the annotated read back.** A
208
+ `qm-anchor` tag the writer kept pins its anchor's start where it stands,
209
+ one it dropped leaves the anchor to the diff, and one naming nothing stored
210
+ reports `qm-anchor[ref]`. The read marks a tag it moved off its anchor's
211
+ start `at="span"` or `at="line"`, which pins nothing.
212
+ - feat(content)!: **an island markdown cannot spell reads as a
213
+ `qm-island` token**, `<qm-island ref="ID"></qm-island>`, which a revise
214
+ keeps as stored: a table's unnamed keys, an equation's unwritable TeX, an
215
+ image's extra props. A token naming no stored island reports
216
+ `qm-island[ref]`; a cold import drops every token, reported as
217
+ `qm-island`. A `Base::Read` merge compares the sides as annotated reads,
218
+ so a token survives it and a moved tag pins through it.
219
+
220
+ ### Anchors on every text surface
221
+
222
+ - feat(content)!: **a table cell's and a footnote note's anchors join the
223
+ comment contract.** `Content::anchor_sites` reads every anchor with its
224
+ `Surface`, prose or a cell, and `anchor_contexts`, the annotated read's
225
+ `anchors` and every `dropped_anchors` receipt list cell anchors, a cell's
226
+ text as its `line`. `RemoveAnchor` removes an id from every cell, an anchor
227
+ `Add` refuses an id a cell holds, and an island op refuses a cell anchor
228
+ whose id the value already holds (`ApplyError::AnchorIdCollision`). A revise
229
+ keeps a cell's anchor where its island continues and the cell's text is
230
+ unchanged. `locate_site` and `reattach` search the cells with the prose.
231
+
232
+ ### Scoped reads
233
+
234
+ - feat(core): **the annotated read of one address.** `Card::body_annotated`
235
+ and `field_annotated` (WASM `doc.annotatedAt(addr)`) read one body or
236
+ richtext field as the revise at that address reads it back, and as its
237
+ `baseMarkdown`.
238
+
239
+ ### Guarded revise
240
+
241
+ - feat(core)!: **a revise states what its writer read.** `Base::Hash` is the
242
+ hash of the value read (`Card::body_hash`, `Card::field_hash`,
243
+ `Document::hash`, `content_hash`, and the typed writer's `hash` /
244
+ `field_hash` through the schema: SHA-256 over `quillmark/hash@1` and the
245
+ key-sorted canonical JSON), and a value that now hashes otherwise refuses
246
+ the write with `edit::stale_base`. `Base::Read` is the markdown read, annotated or not:
247
+ when the value has changed since, the writer's edit and the change merge by
248
+ line (`quillmark_content::delta::merge_lines`), so a person's edit made
249
+ between an LLM's read and its write lands beside the LLM's, and runs of
250
+ lines both changed refuse with `edit::rebase_conflict`. Both carry `current`,
251
+ the hash now. The verbs are `revise_body_against`, `revise_field_against`,
252
+ the writers' `revise_document_against`, and the free
253
+ `revise_import_against`. `EditError` gains `Base(BaseError)` and
254
+ `BoundParseError` gains `Base`.
255
+ - feat(wasm): **`BaseOptions` on every revise.** The writer's `reviseBody` /
256
+ `reviseField` / `reviseDocument` take an optional
257
+ `{ baseHash?, baseMarkdown? }`, read through the writer's `hash()` /
258
+ `hashAt(addr)` or the quill-free `doc.hashAt(addr)` / `doc.hash()`.
259
+ - feat(python): **`base_hash` and `base_markdown` on the writer's revise
260
+ verbs**, read through the writer's `hash()` / `hash_at(field=None,
261
+ card=None)` and the quill-free `doc.hash_at` / `doc.hash()`.
262
+
263
+ ### Markdown export
264
+
265
+ - feat(content): **an export can name what it does not spell.**
266
+ `export::to_markdown_checked` returns `Exported { markdown, losses }`, the
267
+ markdown read back and compared with the content: each `ExportLoss` is a
268
+ mark written over other text or dropped, an island that reads back
269
+ otherwise, or `Text` where the text or its blocks do. Core mints one
270
+ `export::lost_construct` warning per loss (`lost_construct`), its
271
+ `construct` the mark's wire type, the island's type or `text`, a mark's
272
+ `start` and `end` beside it.
273
+ - fix(content): **a `*`, `_` or `~~` mark over edge whitespace keeps its
274
+ text.** `bar ` in bold wrote no bold at all, its delimiter never closing
275
+ against a space; it now writes `**bar** `, the mark over `bar`.
276
+ - perf(content): **`to_markdown` reads each line's marks alone**, so export is
277
+ linear in the marks where it scanned every mark per line.
278
+ - feat(content)!: **a markdown import reports the bidi controls it drops**,
279
+ under `parse::dropped_construct` with `construct: "bidi"`. `Dropped` gains
280
+ `Bidi`.
281
+
282
+ ### Comments
283
+
284
+ - feat(content): **anchor contexts and exact re-attachment.** The `anchor`
285
+ module reads each prose anchor's quote, prefix and suffix off the stored
286
+ text as it is, an inline island's `U+FFFC` slot included
287
+ (`anchor_contexts`), finds the one range a context names (`locate`), and
288
+ re-adds a dropped anchor through the `Add` arm (`Content::reattach`), a range
289
+ over exactly its quote and a point at the one place its context meets. The
290
+ rule never guesses: a quote of `MIN_ANCHOR_QUOTE` (four) scalar values or
291
+ more, or island slots alone, its context agreeing on `ANCHOR_AGREE_USV`
292
+ (eight) each side or as far as it reaches where the field's edge cut it, a match overlapping inserted text where a
293
+ delta is given, and exactly one such place. `Delta::inserted_spans` is
294
+ public. (#2349)
295
+ - feat(core): **`Document::reattach` and `Revised.dropped_anchors`.**
296
+ `Document::anchor_contexts` / `anchor_context(id)` read each anchor's
297
+ `AnchorSelector { id, path, quote, prefix, suffix }`, and
298
+ `Document::reattach(selectors, deltas)` places dropped anchors at their
299
+ paths, path-strict, an id live anywhere in the document, a cell's included,
300
+ answered without a write. `Revised` gains `dropped_anchors`, each
301
+ `AnchorRead { id, line }`, at every rung that revises one body or field, and
302
+ `revise_import` takes the `&Normalized` it names them from. The facade
303
+ re-exports `AnchorSelector`, `Reattached`, `AnchorRead` and `DocPath`.
304
+ (#2349)
305
+ - feat(wasm): **`anchorContexts`, `anchorContext`, `reattach` and
306
+ `droppedAnchors`.** The selector crosses flat, its `path` a `DocPath`
307
+ string; `reattach(selectors, deltas?)` takes a `Record<path, Delta>` and
308
+ returns `{ reattached, orphaned }`. The `Revised` receipt of
309
+ `writer.reviseBody` and `writer.reviseField` gains
310
+ `droppedAnchors: { id, line }[]`. (#2349)
311
+ - feat(python)!: **the revise verbs return the whole receipt.**
312
+ `writer.revise_body` / `revise_field` return `{ delta, warnings,
313
+ dropped_anchors }` and `revise_document` `{ dropped_anchors, warnings, from }`,
314
+ its warnings the same `Diagnostic` objects, where each returned a list of
315
+ them. `doc.anchor_contexts()`, `doc.anchor_context(id)` and
316
+ `doc.reattach(selectors, deltas=None)` expose the handle. (#2349)
317
+ - docs(canon): **`COMMENTS.md` states the comment-anchor contract**: the
318
+ thread outside the document, the id it shares with its anchor, the matching
319
+ rule, drift, table cells, and the hosting rule. (#2349)
320
+
321
+ ### Removed
322
+
323
+ - refactor(wasm)!: **WASM API no consumer uses.** `Document.revise` (the
324
+ 0.133 markdown revise; `revise` is now the content-lane write),
325
+ `rebase`, `loadStored`, `storeFields`, `Quill.seedMain`,
326
+ `Document.markdownRules`, `assignInstances`, `CardReader` and
327
+ `reader.card(i)`, `CardWriter.index` / `kind`, the writer's and reader's
328
+ `document` getters and `LiveSession.backendId` are removed. The migration
329
+ guide's § Removed names each replacement.
330
+ - refactor(core,pdf)!: **public items nothing outside their crate uses.**
331
+ `BUNDLE_MEDIA_TYPE` is removed; `QuillConfig::is_snake_case_identifier`,
332
+ `quillmark_pdf::CHECK_GLYPH` and `FieldSpec::assert_finite_rect` are
333
+ crate-private; `SessionHandle::update` has no default, and the
334
+ `backend::update_unsupported` code no built-in backend raised is gone.
335
+ - refactor(content)!: **`serial::line_kind_to_value` is removed**, and
336
+ `serial::line_kind_from_value`, `container_from_value` and
337
+ `mark_from_value` are crate-private: decode a whole value through
338
+ `lines::serial::from_canonical_value`.
339
+
340
+ ### Dependencies
341
+
342
+ - build(deps): **`serde-saphyr` 2.0**, with the YAML error sanitizer
343
+ stripping its new API names from messages; **Typst's `hayro` 0.7** for the
344
+ PDF form backend's raster; `pyo3` 0.30.1 and `lopdf` 0.46.
345
+ - ci(release): PyPI publishes only where `PUBLISH_PYPI` is set.
346
+
3
347
  ## v0.133.0 - 2026-10-29
4
348
 
5
349
  Upgrade path: [0.132 → 0.133](docs/migrations/0.132-to-0.133.md). The
package/README.md CHANGED
@@ -182,38 +182,9 @@ const doc = Document.storageVersionOf(content)
182
182
  : Document.fromMarkdown(content);
183
183
  ```
184
184
 
185
- The `schema` value (`quillmark/document@0.131.0` for a document holding a font
186
- mark, `@0.129.0` for any other) is the **model version**, not the running crate
187
- version. It is a hand-set constant, bumped only when the `Document` model
188
- itself changes, so every `0.131.x` patch release reads and writes those same
189
- values.
190
-
191
- - **Upgrading is safe.** A newer build reads documents an older build's
192
- *writer* produced. Each schema version's wire format is frozen and never
193
- changes; when the model does change, the new build ships a migration that
194
- converts old payloads on `fromStored`. A document you commit as your
195
- canonical on-disk format keeps loading across crate upgrades until its tag
196
- is retired: 0.130 reads `@0.124.0` and `@0.129.0` only, so resave an older
197
- row with a 0.124 to 0.129 build before taking 0.130, as that release's
198
- migration guide shows. The other exception is a row a host authored a
199
- content construct of its own into — a line `kind`, container, mark `type`
200
- or island `type` outside the vocabulary. Those are refused from 0.113 on;
201
- see that release's migration guide. An island `loss` is not one of them:
202
- 0.115 drops the key, so a row spelling one opens whatever the class and
203
- comes back without it. From 0.126 on, a row whose `$quill` is a lone major,
204
- `memo@1`, or whose body nests past 12 levels is refused too; rewrite it
205
- before upgrading, as that release's migration guide shows.
206
- - **Downgrading is not.** `fromStored` rejects a `schema` version it does not
207
- read, newer than the build or retired, rather than guessing at its format.
208
- Don't feed documents written by a newer build back into an older one.
209
-
210
- `Document.currentStorageVersion()` is the newest tag this build writes. A
211
- save writes the oldest tag that holds the row, so a fresh row can carry an
212
- older tag still read: a row holding no font mark, `table` element, merged
213
- table cell or table `head` 0.130 cannot draw saves as
214
- `quillmark/document@0.129.0`. A tag other than `currentStorageVersion()` is
215
- therefore not a mismatch. Use `storageVersionOf` after `fromStored` throws,
216
- to tell "a version this build does not read" from "corrupt":
185
+ The tag model, what upgrading reads, and the one-time rewrites a release asks of stored rows are in [Persistence](https://quillmark.dev/integration/persistence/).
186
+
187
+ `Document.currentStorageVersion()` is the newest tag this build writes, and a save writes the oldest tag that holds the row, so a tag other than it is not a mismatch. Use `storageVersionOf` after `fromStored` throws, to tell "a version this build does not read" from "corrupt":
217
188
 
218
189
  ```ts
219
190
  try {
@@ -224,13 +195,7 @@ try {
224
195
  }
225
196
  ```
226
197
 
227
- `storageVersionOf` does not validate the payload: it only reads the
228
- `schema` field, returning `undefined` for non-JSON, non-objects, or
229
- payloads that don't carry one.
230
-
231
- In short: persist the `toStored` string, resave rows under a retired tag
232
- before upgrading past it, never downgrade. The full design (including how
233
- migrations are added) is in `prose/canon/DOCUMENT_STORAGE.md`.
198
+ `storageVersionOf` does not validate the payload: it only reads the `schema` field, returning `undefined` for non-JSON, non-objects, or payloads that don't carry one.
234
199
 
235
200
  ### Cards, seeds, and addresses
236
201
 
@@ -254,7 +219,7 @@ document path.
254
219
 
255
220
  `quill.seedDocument()` returns a starter document: one card per kind, each
256
221
  carrying the kind's `seed` from `Quill.yaml` and no other field;
257
- `quill.seedMain()` and `quill.seedCard(kind)` seed one card. All
222
+ `quill.seedCard(kind)` seeds one composable card. Both
258
223
  return the read `Card` shape of `doc.main` / `doc.cards`, which `doc.insertCard`
259
224
  accepts directly:
260
225
 
@@ -291,16 +256,10 @@ the content object, a `text` field as its literal string), and as authored
291
256
  through the transport door. For the `Content` either way read
292
257
  `quill.reader(doc).getContent(addr)`, which decodes through the codec the
293
258
  field's declared type names. Card-scoped verbs take a
294
- `CardAddr` (`{ card? }`) first: `doc.getExt({ card: 2 })`, and the batch below.
295
-
296
- Batch mutation: `doc.storeFields({}, {...})` / `doc.storeFields({ card: index }, {...})`
297
- apply a whole object atomically: on any invalid field nothing is applied and
298
- the thrown error carries one diagnostic per offending field (`path` = field
299
- name). The address is first (never shape-overloaded, since `card` is a legal
300
- field name), and parses strictly: a stray key throws rather than silently
301
- reading as `{}`. The main card is `{}`, or **`MAIN_CARD_ADDR`** (from
302
- `@quillmark/wasm`), a frozen alias that spells the intent:
303
- `doc.storeFields(MAIN_CARD_ADDR, {...})`.
259
+ `CardAddr` (`{ card? }`) first: `doc.getExt({ card: 2 })`. An address parses
260
+ strictly: a stray key throws rather than silently reading as `{}`. The main
261
+ card is `{}`, or **`MAIN_CARD_ADDR`** (from `@quillmark/wasm`), a frozen alias
262
+ that spells the intent: `doc.getExt(MAIN_CARD_ADDR)`.
304
263
 
305
264
  ### Typed writes: `commit*` is the default, `store*` is the quill-free primitive
306
265
 
@@ -317,13 +276,12 @@ on `Document` itself (**store** = verbatim, **set** = typed):
317
276
  on the typed path an undeclared name is a typo, not a fallback. The batch form
318
277
  (`setAll`) is all-or-nothing: an undeclared name aborts the whole write and its
319
278
  per-field diagnostics name every offending field, so a whole-form submit
320
- surfaces every typo `storeFields` would silently absorb. (The raw wasm class
321
- carries the quill-taking `_commitField` / `_commitFields` / `_addCard` /
279
+ surfaces every typo the opaque store would silently absorb. (The raw wasm
280
+ class carries the `_commitField` / `_commitFields` / `_addCard` / `_revise` /
322
281
  `_reviseField` ABI the writer delegates to, hidden from the `.d.ts`.)
323
282
 
324
283
  - **`store*`: the deliberate quill-free primitive.** `doc.storeField(addr, value)`
325
- / `doc.storeFields(cardAddr, {...})` validate only the field
326
- name/depth/kind and store the value verbatim, no quill required. Reach for it
284
+ validates only the field name/depth/kind and stores the value verbatim, no quill required. Reach for it
327
285
  on purpose when you *want* the opaque store: quill-agnostic storage/migration
328
286
  infra that has no bundle and must write regardless of a drifted schema;
329
287
  store-now-validate-later editors holding in-progress input that `commit`
@@ -355,7 +313,7 @@ ed.card(2).reviseBody("**note**"); // a composable card's body;
355
313
  is lazy: it never throws; an out-of-range index throws `IndexOutOfRange` at the
356
314
  write.
357
315
 
358
- #### `DocumentReader` / `CardReader`: the read twin
316
+ #### `DocumentReader`: the read twin
359
317
 
360
318
  `quill.reader(doc)` carries the writer's ephemerality and schema authority:
361
319
 
@@ -364,7 +322,7 @@ const v = quill.reader(doc);
364
322
  v.get("subject"); // the values form: every content leaf as its codec's text, else as stored
365
323
  v.getContent("subject"); // the same read as a `Content`, whichever lane stored it
366
324
  v.bodyMarkdown(); // the main body markdown (quill-free)
367
- v.card(0).bodyMarkdown(); // a card's body markdown; its fields read through get
325
+ v.get({ card: 0 }); // a card's body markdown; its fields read through get
368
326
  v.resolve(); // the render view: blank-filled, coerced, each field tagged with its rung
369
327
  ```
370
328
 
@@ -459,14 +417,7 @@ if (cssPxPerPt > 0) { // 0 while the canvas has no layout
459
417
 
460
418
  ### Schema model
461
419
 
462
- A field declares no `required` key: nothing is required.
463
-
464
- **`default:`** — what an unanswered field renders. With one, the render path
465
- uses it when the document omits the field, and `quill.authoringSchema` carries
466
- it as the field's `default`. Without one, an absent field blank-fills.
467
-
468
- An unanswered field draws no diagnostic. Partial documents are accepted, and
469
- `engine.render(quill, doc)` throws only for malformed input.
420
+ A field declares no `required` key and an unanswered field draws no diagnostic; [`default`](https://quillmark.dev/quills/quill-yaml-reference/#default) is what an unanswered field renders.
470
421
 
471
422
  ### Errors
472
423
 
@@ -500,19 +451,11 @@ throws a real `Error` and attaches the property, so there is no constructor to
500
451
  `instanceof` against. Narrow with `isQuillmarkError`, which also works on errors
501
452
  from any build or WASM instance in the page.
502
453
 
503
- `diagnostics` is always non-empty: length 1 for most failures, length N for
504
- backend compilation errors, and `message` is derived from it. `diagnostics[0]`
505
- is an error. A failed Typst `engine.render` or `engine.open` also carries the
506
- quill's load warnings after its errors, so read each entry's `severity`. The
507
- same shape applies to every throw site:
454
+ `diagnostics` is always non-empty and its first entry is an error; [Error Handling](https://quillmark.dev/integration/error-handling/) has the shape, the code namespaces and the warnings that follow. The same shape applies to every throw site:
508
455
 
509
456
  - `Document.fromMarkdown`: parse errors (missing root `$quill` metadata, YAML
510
457
  errors, `parse::input_too_large` for inputs > 10 MiB).
511
- - `Document` mutators (`storeField`, `insertCard`, the writer's
512
- `set`, etc.): mutator failures carry a namespaced `edit::*` `code` on
513
- `diagnostics[0]` (`edit::invalid_field_name`, `edit::unknown_field`,
514
- `edit::index_out_of_range`, `edit::field_coercion_failed`, …). Route on
515
- `diagnostics[0].code`, never on message text.
458
+ - `Document` mutators (`storeField`, `insertCard`, the writer's `set`, etc.): a namespaced `edit::*` `code` on `diagnostics[0]`.
516
459
  - `engine.render` / `session.render`: backend compilation failures and
517
460
  validation errors.
518
461
  - An object argument carrying a key its verb does not read: the render
@@ -520,8 +463,8 @@ same shape applies to every throw site:
520
463
  key counts, a non-enumerable one and one holding `undefined` included. The
521
464
  argument must be a plain object, its prototype `null`, `Object.prototype`
522
465
  of any realm, or a null-prototype object whose keys count too, so a `Map`, a
523
- class instance or `Object.create({ … })` throws. A payload item, the fields
524
- object `storeFields` takes, and a `$ext` or `$seed` value read their own
466
+ class instance or `Object.create({ … })` throws. A payload item and a
467
+ `$ext` or `$seed` value read their own
525
468
  enumerable keys alone: a non-enumerable or inherited key there is neither
526
469
  read nor refused.
527
470
  The diagnostic names the key or what was passed, and carries no `code`: it