Skip to content

0.134 → 0.135 — the engine mints, lowers and guards: placeholder ids, structural-edit verbs, one spelling per value, and a single writer

Core is single-writer: a stale base refuses

The break no type checker reports: a write under a base the value has moved on from throws edit::stale_base where it rebased. In 0.134 a stale base on apply, revise or replace rebased the write over the change since, kept in an in-memory log, and a markdown base merged a revise onto it. Now a base is the hash the writer read and nothing else, and a value that hashes otherwise refuses the write, carrying current, the hash now, and writes nothing. Core keeps no history of its writes. Writers that answer late and must merge with what landed since, an LLM beside a person typing, share a @quillmark/collab session, whose Yjs document merges them.

// 0.134
const receipt = doc.apply({}, change, { base })   // lands over the change since
if (receipt.rebased) refresh()
writer.reviseBody(reply, { baseMarkdown: read })   // merges onto the change since

// 0.135
try {
  doc.apply({}, change, { base })
} catch (e) {
  if (e.diagnostics[0].code === 'edit::stale_base') readAgain(e.diagnostics[0].args.current)
}
writer.revise({}, reply, { baseHash: writer.hashAt({}) })
Break Surface Action
A stale base on apply, revise or replace throws edit::stale_base, where it rebased Rust, WASM, Python Read again and write from the read; route late writers that must merge through @quillmark/collab
edit::rebase_conflict is retired Rust, WASM, Python Drop its handling; edit::stale_base is the one base refusal
The receipt's rebased is gone Rust, WASM, Python Drop the read
baseMarkdown / base_markdown is gone, and the writer's revise and reviseDocument take { baseHash } alone; another key throws WASM, Python Pass the hash writer.hashAt(addr) or writer.hash() answered at the read
quillmark_content::rebase, Conflict, RebaseError, ReviseError::Conflict, diff::revise_keeping, Base, DocumentBase, BaseError, Receipt::rebased, LOG_STEPS, read_at, lines::delta::merge_lines and rebase_marks_pinned are gone; EditError::Base(BaseError::Stale { .. }) is EditError::StaleBase(StaleBase { .. }) and BoundParseError::Base is BoundParseError::StaleBase; WriteOptions::base, TypedWriter::revise and revise_document_against take the hash as &str Rust Pass Some(&hash) where you passed Some(Base::Hash(&hash))

A revise diffs the writer's markdown against the value, as in 0.134, so what the value holds and markdown does not spell, an empty paragraph, two hard breaks in a row in a cell, an x- mark, is gone from the result. An x- island still refuses to drop (edit::drops_ext_island).

A write takes a Content only in its canonical form

The break no type checker reports: a Content a write takes is refused where normalize would rewrite it. In 0.134 every input path decoded, normalized, then validated, so JSON with a reversed or empty mark, a mark over only a line break, marks or anchors out of order, an unused container, an untrimmed equation tex or a table head: 1 loaded, silently rewritten. Now replace, diff, invert, the structural edits, exportMarkdown and every other verb taking a Content throw, as do Rust's Content::from_value (Invariant::NotCanonical, naming the part, or the invariant the decoded value breaks) and Card::replace. normalize is the one input path that still rewrites: a producer that builds a value by hand normalizes it first. A value the engine answered, from importMarkdown, a read, a receipt or repair, is already canonical.

// 0.134: lands, normalized
// 0.135: throws where `normalize` would rewrite it
doc.replace({}, handBuilt)
doc.replace({}, normalize(handBuilt))

A stored row reads leniently. Document.fromStored (Rust Document::from_stored, Python Document.from_stored) reads a body or field value a write would refuse as its canonical form, and adds a content::noncanonical_read warning to doc.warnings at each one, so the host stores the document again and the row's hash moves once. The lenient read stays: a row never re-stored still opens.

Break Surface Action
A Content that normalize would change is refused by every verb taking one but normalize Rust, WASM, Python Pass the value through normalize where a producer builds it by hand
Content::from_value refuses what it normalized; Content::from_value_lenient is the stored read Rust Call from_value_lenient where you read a stored value, from_value where you take one to write
normalize no longer throws where replace would; replace throws where normalize would rewrite WASM Normalize, then replace
Document.fromStored carries content::noncanonical_read warnings, where it carried none Rust, WASM, Python Re-store a document whose warnings hold one; serde_json deserialization of a Document reads the same without the warnings
Invariant gains NotCanonical and BreakInCell; StoredContent is {content, rewritten} Rust Add the match arms; read .content where you read .0

A table cell is one paragraph

The break no type checker reports: a table cell holds one paragraph, its lines parted by hard breaks (U+2028). In 0.134 a cell held paragraphs, and | one<br><br>two | or <td><p>one</p><p>two</p></td> imported as two; the editor holds one per cell and lost every write to such a table. Now both import as one, a hard break, two, and a <p> boundary in an HTML cell is a hard break where it dropped as parse::dropped_construct. A value with a paragraph break in a table cell is refused at every write as Invariant::BreakInCell, a step leaving one refuses, and repair joins one, reported with inserts: true. A stored cell of several paragraphs reads joined under content::noncanonical_read. A footnote's note is one paragraph the same way: the import joins a note's paragraphs with hard breaks, and a run of breaks as a cell's, so [^1]: one<br><br>two and a <br> ending a paragraph a continuation follows each read as one hard break where they imported as a note of two paragraphs that could not be stored; now a value holding a \n in a note is refused as a cell's is, joined on a stored read and by repair. An x- island's slots keep their paragraphs.

// 0.134: { text: "one\ntwo", blocks: [b0, b1] }
// 0.135: { text: "one\u2028two", blocks: [b0] }
importMarkdown('| h |\n| - |\n| one<br><br>two |').content.islands[0].slots.s1
Break Surface Action
<br><br> in a cell imports as one hard break; a <p> boundary in an HTML cell as one too Rust, WASM, Python Nothing to change; a cell of paragraphs no longer exists to edit
A Content whose table cell holds a \n is refused Rust, WASM, Python Join a cell's paragraphs with U+2028, or pass it through normalize
Export never writes a table as HTML for a cell of several paragraphs Rust, WASM, Python Nothing to change
A Content whose footnote note holds a \n is refused, as Invariant::BreakInCell, whose message names the slot and island Rust, WASM, Python Join a note's paragraphs with U+2028, or pass it through normalize

A step holding a key it does not define is refused

A change step decodes only the keys its kind defines. In 0.134 an unknown key was ignored, so a misspelled delete turned a guarded replace into a pure insert and a misspelled quote dropped the guard. Now the change is refused at the decoder and nothing is written: ParseError::UnknownKey { at, key } in Rust, a thrown Error naming the key in JS, a ValueError naming it in Python.

// 0.134: lands as an insert of "hi" before "hello"
// 0.135: throws, naming `delet`
doc.apply({}, { steps: [{ splice: { at: 0, delet: 'hello', insert: 'hi' } }] })
Break Surface Action
A step object holding a key its kind does not define is refused Rust, WASM, Python Spell each step's keys as CONTENT.md § Changes lists them; the attrs and props bags stay open
quillmark_content::wire::ParseError gains UnknownKey Rust A match over it adds the arm

A write mints from the value it lands on

A revise or replace mints from the value it lands on and the write alone, never the card's history of recent writes, so one write over equal values stores equal bytes on every card.

Break Surface Action
A revise or replace no longer mints past ids only the card's history held Rust, WASM, Python Nothing to change; a card with history stores what a fresh one does

Formatting inside a link's text, which 0.134's export dropped and every revise with it, is now written inside the link's brackets, so a revise keeps it. to_markdown_checked also names an x- block or mark among its losses (Loss::ExtBlock, Loss::ExtMark) where it reported none.

Break Surface Action
diff::revise answers ReviseError (Import or Refused) where it answered ImportError Rust Match ReviseError::Import where you matched the import error

Anchor text is spelled as stored

anchorContexts, anchorContext, and a content write's droppedAnchors spell a quote, prefix and suffix as the value stores them: a hard break is U+2028 and a cell's block break is one \n, as movedAnchors.before already did. In 0.134 they spelled a hard break as \n and a cell's block break as \n\n, so one receipt spelled an anchor two ways. reattach places a selector in either spelling for this release: one stored under 0.134 still finds its text.

Break Surface Action
A selector's quote, prefix, suffix hold U+2028 for a hard break Rust, WASM, Python Compare selectors through reattach, or normalize both spellings before comparing them yourself

Content values, changes and markdown are bounded

A value, change or markdown input past a bound now refuses where it was read in full. No document a person writes nears these; a host that generates content should check it stays inside them.

Bound Value Refused as Where
Marks on one text, a field's or a slot's 64 + 4 per character, each mark counted once per block and line it spans before it is cut at each Invariant::TooManyMarks, edit::too_many_marks decoding a value (fromStored, a field read, replace, diff, invert); a change, at the step that would pass it; repair keeps those in order up to it
Anchors on a value 100 000 Invariant::TooManyAnchors, edit::too_many_anchors decoding; a change; repair keeps those in reading order up to it
Markdown one import reads 10 MiB (MAX_MARKDOWN_SIZE) ImportError::TooLarge, edit::input_too_large revise, reviseBody, reviseField, importMarkdown and every other import, as a parse already was
A content value's text and its slots' texts, at a binding 10 MiB edit::input_too_large every WASM and Python input path taking a Content or RawContent
Steps in one change, nested ones counted 16 384 (MAX_CHANGE_STEPS) edit::too_many_steps apply in every binding and Card::write, and every WASM and Python input path taking a Change
Characters footnote notes copy past each note's first reference 1 000 000 (MAX_NOTE_COPIES) a reference past it stays as its [^label] text, warning footnote:size every markdown import

Before, a few kilobytes of x- marks of distinct attributes over many blocks decoded into gigabytes, and a note cited thousands of times into tens of megabytes; on wasm32 either trapped. A Python Content past a bound raises the diagnostics error under these codes, where an invalid value raises ValueError.

Break Surface Action
Invariant gains TooManyMarks and TooManyAnchors; ImportError gains TooLarge; DroppedConstruct gains FootnoteSize; EditError gains InputTooLarge, TooManySteps, TooManyMarks and TooManyAnchors Rust An exhaustive match adds the arms
A change refused for its marks or anchors throws edit::too_many_marks / edit::too_many_anchors where it threw edit::content_apply Rust, WASM, Python Route the new codes

New, with nothing to migrate: MAX_MARKDOWN_SIZE, MAX_NOTE_COPIES, MARKS_PER_CHAR, MARKS_BASE, max_marks, MAX_ANCHORS and Content::check_bounds in quillmark-content; MAX_CHANGE_STEPS, check_change and check_content_value in quillmark_core::document.

A write mints the ids its change leaves open

The break no type checker reports: an id may no longer open with ?. A record a change puts may carry a placeholder, ? and an id, which the write that lands the change fills with an id it mints and names in the receipt's new minted; a value holding an id that opens with ? is no longer valid.

// 0.134: the client minted, and could collide with another client's mint
doc.apply(addr, { steps: [{ splice: { at, insert: '\n', blocks: [{ id: myMint(), kind: 'para' }] } }] }, { base })

// 0.135
const { minted } = doc.apply(addr, { steps: [{ splice: { at, insert: '\n', blocks: [{ id: '?new', kind: 'para' }] } }] }, { base })
minted['?new'] // 'b1'
Break Surface Action
An id opening with ? is invalid: a value holding one refuses, and a change putting one is a placeholder the write fills Rust, WASM, Python Rename any stored id that opens with ? before upgrading
structural::split_block takes Option<&str>; indent, outdent, wrap and an id-less split_block put placeholders (?0, ?1, …) that apply fills Rust Pass Some(id) for a chosen id, and read the ids from Applied::minted
Card::apply, revise, replace, write and write_in take impl Into<WriteOptions> where they took Option<&str> Rust A None or Some(hash.as_str()) still compiles; an Option<&String> needs .map(String::as_str)
Edit gains Structural; Receipt and Applied gain minted Rust An exhaustive match adds the arms; a struct literal adds the field

New, with nothing to migrate: Receipt.minted in every binding ("minted" in Python), ids::is_placeholder and the mint module in quillmark-content.

Structural edits are document verbs, and a range lowers exactly

The WASM structural-edit functions that answered a Change over a whole Content are gone; the structural edits are Document verbs that write and answer a Receipt, as apply does, refusing a stale base. diffRange(before, after, { from, to }) lowers the range ProseMirror's ReplaceStep names as one exact splice, so typing a letter into a word is a one-letter splice rather than a rewrite of the word; doc.blockAt(addr, offset) answers the block at an offset.

// 0.134
doc.apply(addr, splitBlock(doc.read(addr), at, 'b9'), { base })

// 0.135
const { minted } = doc.splitBlock(addr, at, { base })
Break Surface Action
splitBlock, joinBlocks, deleteRange, indent, outdent and wrap are no longer module functions WASM Call doc.splitBlock(addr, at, opts), doc.joinBlocks(addr, id, opts), doc.deleteRange(addr, start, end, opts), doc.indent(addr, first, last, opts), doc.outdent(addr, first, last, opts) or doc.wrap(addr, first, last, kind, opts); splitBlock takes the new id as opts.id

New, with nothing to migrate: diffRange and doc.blockAt in WASM; diff::diff_range, Card::block_at and the Card structural-edit verbs in Rust.

A text field is stored as a content object spelling codec: "plain"

The break no type checker reports: getStored on a conformed text field answers a content object, not a string. A text field is stored as Content like a richtext one, its codec: "plain" key marking the literal codec, so it holds comment anchors and ids, and toMarkdown still writes it as its literal text. A host that read getStored as the string reads it with reader.get (the literal text) or reader.getContent.

// 0.134
const title = doc.getStored({ field: 'title' })          // "a *literal* line"

// 0.135
doc.getStored({ field: 'title' })   // { text: "a *literal* line", blocks: [...], codec: "plain" }
quill.reader(doc).get('title')      // "a *literal* line"
Break Surface Action
A conformed text field's stored value is a Content spelling codec: "plain" Rust, WASM, Python Read it through reader.get or reader.getContent; a stored string still reads, and rewrites to the object at its next bound load
field_hash of a text field hashes its content, ids and anchors included, not its literal text Rust, WASM, Python A hash read before the upgrade is stale once the field conforms; read it again
Content gains codec; Invariant::NotPlain refuses a plain value holding a mark, an island, a container or a block other than a paragraph Rust, WASM, Python A Rust struct literal of Content or Raw names codec (Codec::Rich for what it built before)
The render plate's text value carries codec: "plain", and a text blank is the empty plain content Rust, WASM, Python A plate reads it through qm.str or as content, unchanged
A text value's literal text spells a paragraph break as a blank line and a hard break as a lone \n, both ways: a\n\nb imports as two paragraphs, where it imported an empty one between them, and a value of two paragraphs reads as a\n\nb, where it read as a\nb and revised as one paragraph Rust, WASM, Python Spell a paragraph break in a text value as a blank line; a value stored with empty paragraphs between its lines reads back as itself

A stored row needs no migration: a text field holding a string is the authored form, and the bound input path conforms it once. reattach answers a whole-field selector, an empty quote, prefix and suffix at a path naming a value that holds no content, as reattached, so a comment can sit on an enum, a number, a date or a boolean (COMMENTS.md § "Text and scalar fields").

An anchor step places or moves, never both

The break no type checker reports: an anchor step naming an id the value already holds now throws edit::content_apply. In 0.134 {id, start, end} placed a new anchor or silently moved the held one, so two hosts minting one id merged two comments. Now {id, start, end} places, and refuses an id the value or any slot holds; {id, start, end, move: true} moves the anchor its text holds, and refuses one it does not. diff, revise, invert and the receipts' applied write moves as move: true.

// 0.134: moves c1
doc.apply({}, { steps: [{ anchor: { id: 'c1', start: 6, end: 11 } }] })
// 0.135
doc.apply({}, { steps: [{ anchor: { id: 'c1', start: 6, end: 11, move: true } }] })
Break Surface Action
A placement of a held anchor id throws edit::content_apply Rust, WASM, Python Add move: true to a step that moves an anchor
A move of an id the text does not hold throws edit::content_apply Rust, WASM, Python Place it instead; a move across slots is a removal and a placement
AnchorStep::Set is AnchorStep::Place and AnchorStep::Move; ApplyError gains AnchorHeld Rust Match the new arms

repair answers the change from the merged state

repair answers {content, repairs, change}: change takes the merged state's text to the repaired value's, so a peer maps a caret, or a change made against the repaired value, through it with mapPos. Whether and by whom a repair is written back is @quillmark/collab's; inserts: true marks a mend that lands twice when it is written back twice.

The break no type checker reports: a range anchor that starts or ends on a heading's hard break shrinks off it when repair turns the break into a space, as apply moves an anchor over a rewritten character, so the anchors repair answers agree with change.

const { content, change } = repair(merged)
caret = mapPos(change, caretInMerged).pos
Break Surface Action
A range anchor on a heading's repaired hard break shrinks off it Rust, WASM, Python Read the anchor from content, not from the merged state
repair::repair answers (Content, Vec<Repair>, Change) Rust Destructure the third field, or ignore it with _
Repaired gains change WASM A strict equality over the whole result adds it

New, with nothing to migrate: repair in Python, and repair::change_in in Rust, which counts a repair's change in a host's units.

movedAnchors reports drift by position, a point included

The break no type checker reports: movedAnchors names anchors it left out in 0.134. It named only a range anchor whose quote changed, so a point never reported, and a quote relocated into another sentence, or a tag the writer moved, reported nothing. Now it names every surviving anchor the write moved, a point included, and each entry carries reason, the first of these that holds:

  • position: an anchor step put it other than where the write's text edits carry it, or in another slot;
  • block: its start sits in another block;
  • context: its quote, or the eight characters either side of it, read otherwise. Typing beside an anchor reports it.
for (const { id, reason } of receipt.movedAnchors) {
  if (reason !== 'context') reviewComment(id)
  refreshSelector(id)
}
Break Surface Action
movedAnchors names points, block moves, step moves and edits within eight characters of an anchor Rust, WASM, Python Re-read the selector of each anchor named; filter by reason where a host only wants some
Moved gains reason: MoveReason; each entry on the wire gains reason Rust, WASM, Python A Rust struct literal adds the field; a strict equality over an entry adds reason

The writer's revise answers the content Receipt

The break no type checker reports in Python, and in JS where the result is untyped: writer.reviseBody / reviseField (and revise_body / revise_field) answer the receipt doc.revise answers, { applied, hash, droppedAnchors, movedAnchors, warnings, minted }, where 0.134 answered { delta, warnings, droppedAnchors }. applied is the change from the value before, which mapPos maps a position through in place of the line delta; a text field's is one splice over its literal text. droppedAnchors entries are the selectors reattach takes, not { id, line }. reattach takes each path's applied where it took a delta.

// 0.134
const { delta, droppedAnchors } = writer.reviseBody(md)
const caret = mapThroughDelta(delta, oldCaret)
doc.reattach(selectors, { 'main.body': delta })

// 0.135
const { applied, droppedAnchors } = writer.revise({}, md)
const caret = mapPos(applied, oldCaret).pos
doc.reattach(selectors, { 'main.body': applied })
Break Surface Action
The writer's revise receipt is { applied, hash, droppedAnchors, movedAnchors, warnings, minted } WASM, Python Read applied for delta; read each dropped anchor as a selector
reattach's second argument maps a path to a Change Rust, WASM, Python Pass the receipt's applied
Revised is gone: every revise_* verb on Card, CardMut, TypedWriter and CardWriter returns Receipt, its warnings ImportWarnings Rust Read applied for delta; anchor warnings with receipt.diagnostics(Some(&path)); count applied in a host's units with in_units

Every write on Document validates, and the schema-free writes are unchecked

The break no type checker reports: a content value in a field is held as a body's is. storeField and insertCard stored a field's value exactly as written, so a content object whose keys came in another order compared equal and stored other bytes, exported as a YAML tree with its anchors, and an invalid one stored and loaded where the same value as a body was refused. Now each content value in a field, at any depth (an object holding a text string and a blocks array), decodes as a body write's does and stores in its canonical form: an invalid one throws edit::field_decode, and a load refuses a stored row holding one. A field's string, number or other object is stored as before.

The writes no quill schema checks move under one namespace in every surface, so a call site says which lane it writes through: doc.unchecked.storeField(addr, value) and doc.unchecked.insertCard(card, at?) (WASM), doc.unchecked.store_field(name, value, card=None) and doc.unchecked.insert_card(card, at=None) (Python), doc.unchecked().store_field(card, name, value) and doc.unchecked().insert_card(at, card) (Rust). insertCard answers the placed card's $id.

// 0.134
doc.storeField('title', 'x')
doc.insertCard(quill.seedCard('note'), 0)

// 0.135
doc.unchecked.storeField('title', 'x')
const id = doc.unchecked.insertCard(quill.seedCard('note'), 0)
Break Surface Action
A content value in a field stores canonical; an invalid one throws edit::field_decode, at the write and through a CardInput's payload items Rust, WASM, Python Pass a hand-built value through normalize first
Document.fromStored refuses a row holding a field content value that does not read Rust, WASM, Python Repair the row's field, or drop it, before loading
doc.storeField and doc.insertCard are doc.unchecked.storeField and doc.unchecked.insertCard; insertCard returns the card's $id WASM Insert .unchecked
doc.insert_card is doc.unchecked.insert_card, returning the $id; doc.unchecked.store_field is new Python Insert .unchecked
Document::push_card and insert_card are doc.unchecked().insert_card(at, card), at: None appending, answering the id; CardMut::store_field / store_fields are doc.unchecked().store_field(card, name, value); Card::store_field on a card not yet placed stays Rust Call through unchecked()

addCard answers the placed card's $id

Break Surface Action
TypedWriter::add_card answers CardAdded { id, warnings }, where it answered the warnings alone Rust Read .warnings; .id is the placed card's $id
writer.addCard returns { id, warnings } (CardAdded), where it returned Diagnostic[] WASM Read .warnings; .id is the placed card's $id
writer.add_card returns {"id": ..., "warnings": [...]}, where it returned the list Python Read ["warnings"]; ["id"] is the placed card's $id

One markdown import, one export, and one revise

The break no type checker reports: every revise verb is the content revise at an address, so a typed reviseField or reviseDocument now relocates a moved quote of four characters or more. An anchor whose paragraph a rewrite moves now follows it rather than dropping, so a host that read droppedAnchors to orphan a thread sees it in movedAnchors instead. A text field's revise diffs by word, as a body does, where it diffed by character. A value whose markdown imports to one that does not validate is refused by the address revise with edit::content_apply, and reviseDocument keeps its stored value with an edit::content_apply warning at its path, rather than storing a value the document cannot save.

Markdown crosses through one import and one export, and an annotated option, never a second name, says whether anchors and islands survive:

// 0.134
const doc = Document.fromMarkdown(md)
const whole = doc.toAnnotatedMarkdown()
const read = doc.annotatedAt({ card: 0 })
const body = doc.bodyMarkdown()

// 0.135
const doc = Document.parse(md)
const whole = doc.toMarkdown(undefined, { annotated: true })
const read = doc.toMarkdown({ card: 0 }, { annotated: true })
const body = doc.toMarkdown({})
Break Surface Action
Document.fromMarkdown is Document.parse WASM, Python Rename the call
doc.toAnnotatedMarkdown(), doc.annotatedAt(addr) and doc.annotated(addr) are doc.toMarkdown(addr?, { annotated: true }) WASM Pass the address, or undefined for the whole document
doc.bodyMarkdown(cardAddr?) and reader.bodyMarkdown() are gone WASM Call doc.toMarkdown(cardAddr ?? {}); a field address reads that value's markdown
exportMarkdown(content) takes { annotated } WASM Nothing to change for the plain export; pass { annotated: true } for the tags and tokens
doc.annotated(field=, card=) and reader.body_markdown() are gone Python Call doc.to_markdown_at(field=, card=, annotated=True), or without annotated for the plain read; doc.to_markdown(annotated=True) reads the whole document
quillmark_core::Content is the content model, where it was the line form; quillmark::Lines is gone Rust Read a body with card.body() and write one with Card::replace
Normalized is Lines, into_normalized is into_lines, Dropped is DroppedConstruct; quillmark_content::lines is hidden Rust Rename; a renderer or codec reaching the line form names quillmark_content::lines
body_lines, overwrite_body, overwrite_field, revise_body_content, revise_field_content, revise_import and revise_import_against are gone Rust Write a Content with Card::replace; revise markdown with Card::revise, or the typed TypedWriter::revise
AnchorRead, Annotated, ImportError, ImportWarning and DroppedConstruct live in quillmark_content::markdown Rust Import them from there, or from quillmark_core::document as before
The parity corpus holds no lines key; reimports is a Content Corpus readers Read content
quillmark_content::gestures is structural, its Gesture is StructuralEdit, and Edit::Gesture is Edit::Structural Rust Rename
Usv is Scalar, Content::len_usv is len_scalars, ANCHOR_CONTEXT_USV is ANCHOR_CONTEXT_SCALARS, and usv::char_to_byte is units::char_to_byte Rust Rename
markdown::Loss::Line is Loss::Mark, Loss::Island and Loss::Text, over the model's MarkKind and island ids Rust Match the three arms
quillmark::Delta and quillmark_core::session::{Delta, Assoc, Op} are gone, Content::lines_from_value is quillmark_content::migrate::lines_from_value, and BlockConstruct::tally is crate-private Rust A content change is quillmark_content::change::Change; a renderer reaching the line form calls migrate::lines_from_value

One dropped-anchor shape, one annotated export

Break Surface Action
DocumentRevised.dropped_anchors holds AnchorSelectors (droppedAnchors: AnchorContext[] in WASM, selector dicts in Python), where it held { id, path, cardId, line } Rust, WASM, Python Pass them to reattach as a value write's droppedAnchors are; read quote where you read line
exportMarkdown(c, { annotated: true }) answers { markdown, anchors }, each anchor { id, line } WASM Read .markdown
doc.to_markdown(annotated=True) answers { "markdown", "anchors" }, each anchor { id, path, card_id, line }, and doc.to_markdown_at(..., annotated=True) answers { "markdown", "anchors" }, each { id, line } Python Read ["markdown"]

New, with nothing to migrate: Python's import_markdown, export_markdown(content, annotated=), normalize, is_inline and is_plain, the module functions WASM already had.

Storage writes quillmark/document@0.135.0

The break no type checker reports: a 0.134 build refuses a row 0.135 saves. 0.135 adds the codec key to a content value, which a 0.134 reader does not decode, so the tag moves and an older build reads the row as a version it does not know rather than a text field as rich text. A row under 0.134.0 loads unchanged and saves under 0.135.0, with no bytes but the tag moving. The tree is the same.

Break Surface Action
Every save writes quillmark/document@0.135.0 Rust, WASM, Python Upgrade every reader of a store before any writer; currentStorageVersion() answers the new tag
Document::hash moves with the tag Rust, WASM, Python A whole-document hash read before the upgrade is stale; read it again
STORAGE_V0_135_0 and StoredDocument::V0_135_0 are new Rust A match over StoredDocument adds the arm

A line-form value loads migrated, and warns

A row holding the v1 line form, in its body, a field at any depth or a $seed overlay value, under any tag, loads with each such value in the content model and a content::noncanonical_read warning at its path, a $seed value's at its card's. In 0.134 a $seed value kept its line-form spelling and a migrated body or field warned nothing. Storing the document writes each value back in the model, so a resave over every row reports the rows still holding the line form and rewrites them. A $ext value stays as stored. The line form is readable in this release so that resave can run; a later release stops reading it.

Break Surface Action
A line-form body, field or $seed value warns content::noncanonical_read on load Rust, WASM, Python Store each document whose warnings hold one; resave every row before upgrading past the release that stops reading the line form
A $seed value reads in the content model where it read as stored Rust, WASM, Python Read a $seed content value as a Content

A content value or change holding a key its shape does not define is refused

The break no type checker reports: a Content or Change carrying an extra key throws where it decoded. In 0.134 the decoder read the keys it named and dropped the rest, so a key a newer build adds would have vanished on an older read, and a misspelled pth in a splice's block record landed the block at the root. Now the value, each block, container, mark, anchor and island in it, each slot, a change's top level and every record a step carries hold only the keys their shape defines, and a built-in member's attrs only the entries it names (level, lang, ordered, start, checked, url, name). Island props and an x- member's attrs stay open. A stored row's value reads leniently: the extra key drops and the read warns content::noncanonical_read.

Break Surface Action
An unknown key on a value, record, slot or change, or a foreign attrs entry on a built-in, is ParseError::UnknownKey Rust, WASM, Python Drop the key, or carry the data in island props or an x- member

A revise of a short paragraph keeps its neighbours' records

A diff now pairs lines by the words its word diff reads, so punctuation beside a word no longer makes it another word, and a rewrite of the first block keeps the \n that opens the next. In 0.134 Alpha. → Alpha, edited. replaced the whole line and re-minted the record of the paragraph after it. Now its record stays.

Break Surface Action
A revise or replace keeps more block ids Rust, WASM, Python Nothing to change; read ids from the receipt

Error enums are #[non_exhaustive]

EditError, StorageError, WireError, core's ParseError, and the content crate's ApplyError, MoveReason, Repair, Reason, Invariant and ParseError may gain variants in a minor release.

Break Surface Action
An exhaustive match over one of them needs a wildcard arm Rust Add _ =>

New, with nothing to migrate: quillmark re-exports CardMut, Edit, Located, Target, Unchecked, WriteOptions, Change, Dropped, Moved and MoveReason, so a host writing content names the facade alone.

The body is an address: one revise, one get, one hash

The writer revises the body and a field through one verb, and the reader and the hashes read the body through the field door, the body being the address with no field. reviseBody and reviseField fold into revise(addr, text, opts), which takes the Addr reader.get and hashAt already take. Nothing else changes: a field revise is still typed and conformed, a body revise still the content revise, and the receipt is the same Receipt.

// 0.134
writer.reviseBody(md, { baseHash })
writer.reviseField('summary', text)
writer.card(0).reviseBody(md)

// 0.135
writer.revise({}, md, { baseHash })
writer.revise('summary', text)
writer.card(0).revise({}, md)
# 0.134
writer.revise_body(md, card=0, base_hash=h)
writer.revise_field("summary", text)
reader.get("summary"); doc.body

# 0.135
writer.revise(md, card=0, base_hash=h)
writer.revise(text, "summary")
reader.get("summary"); doc.read()
Break Surface Action
writer.reviseBody(md, opts) and writer.reviseField(name, text, opts) are writer.revise(addr, text, opts), the body {}; the card writer's are card(i).revise(addr, text, opts), addr a field name or { field? } WASM Rename; pass {} for a body
writer.revise_body(md, card=) and writer.revise_field(name, text, card=) are writer.revise(text, field=None, card=None, ...) Python Rename; the text is first, the field second
reader.get(name, card=) and reader.get_content(name, card=) take field=None, which reads the body Python Nothing to change for a field; read a body with no field
Document.body is gone Python Call doc.read(), or read doc.main["body"]
TypedWriter / CardWriter revise_body, revise_body_against, revise_field, revise_field_against and field_hash are revise(target, text, base) and hash_of(target), target a Target or a field name Rust revise(Target::Body, md, None); revise("summary", text, Some(base))
TypedReader / CardReader body_markdown is gone; get and get_content take a Target or a field name Rust get(Target::Body) reads the body's markdown, get_content(Target::Body) its Content
Card and CardMut revise_body, revise_field, revise_body_against, revise_field_against, body_hash, field_hash, body_annotated and field_annotated are gone Rust Card::revise(target, md, opts), hash_of(target) and annotated(target); a typed field revise goes through TypedWriter::revise

Delta leaves @quillmark/wasm

No verb of @quillmark/wasm takes or answers a Delta: a content write speaks Change, and its receipt's applied is what mapPos maps a position through. The type is gone from the runtime's declarations, so code that imported it as the shape of a text change it builds itself declares it there, or takes it from the package that hands it one (@quillmark/collab).

Break Surface Action
The Delta type is gone WASM Declare { ops: ({ retain: number } \| { insert: string } \| { delete: number })[] } where you build one, or import it from @quillmark/collab
lines::delta::Delta, Op and Assoc no longer derive Serialize / Deserialize, and rebase_onto, rebase_pinned and rebase_marks are carry_onto, carry_pinned and carry_anchors Rust Rename; the line form stays hidden from the documented surface