Skip to content

0.100 → 0.101 — core's published surface loses what it could not honour, and the change bundle becomes a struct

Documents and stored blobs are unaffected, and so is every binding but one. Most breaks are Rust-only, and each removes something core published without being able to stand behind it — a constructor whose contract no out-of-crate caller could honour, a second door the backends disagreed about, a projection that had to guess a field's type, a mutation surface whose invariants lived in prose.

The change-bundle verbs are the exception: they trade three positional op arguments for one ChangeBundle struct, because the bundle grows a fourth channel. On the WASM surface that channel is purely additive — applyChange takes a new optional islandOps, and a bundle that omits it behaves as before.

The quillmark facade grows re-exports in the same release — additive, and the reason a consumer following the docs no longer needs a direct quillmark-core dependency.

Break Surface Action
Document::from_main_and_cards becomes crate-internal Rust Document::new + the mutators, or TryFrom<StoredDocument> for stored data
QuillConfig::from_yaml removed Rust QuillConfig::from_yaml_with_warnings
Quill's five file queries move to FileTreeNode Rust insert .files()
Card's four schema-free field readers become crate-internal Rust quill.reader(&doc)
Payload's mutation half becomes crate-internal Rust store_field / store_ext / store_seed_namespace / TypedWriter
LiveSession::apply takes a &Document Rust Pass the document; delete the check-then-compile preamble
Quill::check_quill_reference / QuillConfig::check_quill_reference become crate-internal Rust compile_checked for a render; for the pairing alone, compare doc.quill_reference() yourself
apply_field_change / apply_body_change / apply_field_richtext_change take one &ChangeBundle Rust Wrap the delta and op lists in a ChangeBundle
change_bundle_from_value returns ChangeBundle, not a 3-tuple Rust Destructure the struct's fields

Document::from_main_and_cards is no longer public

Its three invariants — main carries $quill, composable cards carry neither $quill nor $seed — were debug_asserts. In a release build they were gone, so a main card without $quill constructed a Document that panicked on the next quill_reference() or to_plate_json(). Both card constructors are public and both produce such a card, so the panic was reachable from safe API with no written precondition.

Every caller in the tree pre-validates, and the one that takes external data (TryFrom<StoredDocument>) checks all three and returns StorageError::Malformed instead. pub(crate) keeps them all compiling and deletes the panic path rather than documenting it.

// 0.100
let doc = Document::from_main_and_cards(main, cards);

// 0.101 — programmatic construction
let mut doc = Document::new("invoice@1.0.0".parse()?);
let mut w = quill.writer(&mut doc);
w.set("customer", row.name)?;
for item in row.items {
    w.add_card("line_item", [("desc", item.desc)], None, None)?;
}

// 0.101 — stored data
let doc = Document::try_from(stored)?;   // checks the invariants, no panic path

PROGRAMMATIC.md already named Document::new plus the mutators as the authoring path; this is the removal of the parallel one.

QuillConfig::from_yaml is removed

It flattened the Vec<Diagnostic> its sibling returns into a joined fmt_pretty() string inside a Box<dyn Error>, so code, path, hint, location, and source chain were gone by the time a caller saw the failure — routing on a code meant parsing message text, which is the thing ERROR.md exists to prevent. It was the crate's only boxed error, and the obvious-looking name is precisely why an external consumer would reach for the lossy one.

// 0.100
let config = QuillConfig::from_yaml(&yaml)?;

// 0.101
let (config, warnings) = QuillConfig::from_yaml_with_warnings(&yaml)?;

from_yaml_with_warnings collects errors exhaustively — every problem in the file, not just the first — and each is a Diagnostic carrying its code, location, and hint. To drop the warnings, .map(|(config, _)| config). To recover the old message text:

let msg = diags.iter().map(|d| d.fmt_pretty()).collect::<Vec<_>>().join("\n");

Loading a whole quill is unchanged: Quill::from_tree and the quill_from_path helpers already returned structured diagnostics and are the path for anything that is not a bare Quill.yaml string.

Quill's file queries move to FileTreeNode

Five queries lived on Quill as a second way to ask the bundle a question quill.files() already answered. Three were one-line delegations; the two with logic were tree logic on the wrong type. quill.files() is now the single file-query door, and the backends stop disagreeing about which spelling to use.

0.100 0.101
quill.get_file(p) quill.files().get_file(p)
quill.file_exists(p) quill.files().file_exists(p)
quill.dir_exists(p) quill.files().dir_exists(p)
quill.find_files(pat) quill.files().find_files(pat)
quill.list_directories(d) quill.files().list_directories(d)

Behaviour is unchanged, including the two that moved: find_files still sorts its matches and answers an invalid pattern with nothing, and list_directories still joins against its dir_path argument, so its results stay relative to the receiver. FileTreeNode is already in the quillmark facade's re-export list, so nothing new needs naming.

The schema-free field readers become crate-internal

Card::field_markdown, Card::field_plaintext, Card::field_richtext, and Card::field_plaintext_content projected a field with no schema in hand. Which means they had to guess: a richtext string is markdown and a plaintext string is literal text, so the same bytes decode two ways and only the declared type says which — and an undeclared field name read back as absent rather than as the typo it was.

No surface offered the capability. WASM's getMarkdown refuses a field explicitly, Python's markdown reads are body-only, and BINDINGS.md names no field-projection read lane. The schema-bound reader is the door:

// 0.100
let md = card.field_markdown("subject");        // Option<Result<String, _>>

// 0.101
let r = quill.reader(&doc);
match r.get("subject")? {                        // Err(UnknownField) for a typo
    Some(ReadValue::Markdown(md)) => ,
    None => ,                                   // absent
}

get interprets by declared type and get_content reaches the corpus at the other end of the codec. Absence returns, mismatch raises, an unknown name is a typo — the authority the quill-free projection could not have.

RichtextDecodeError follows them down. It was public only as their error type; the schema-bound reader converts it into EditError::FieldRichtextDecode, which is what a caller has always actually seen. Card::body_markdown is unaffected: a body's type is a format fact, not a schema fact.

Payload becomes a read view

PROGRAMMATIC.md promises that "every mutator enforces the same field-name, depth, and kind invariants the Markdown parser does, so a constructed document cannot be invalid." Payload's mutation half was the hole in that: items_mut handed out the raw item list and documented the obligation it could not enforce — at most one $quill / $kind / $ext / $seed, no duplicate field keys, every field name matching [A-Za-z_][A-Za-z0-9_]*. Same defect class as Document::from_main_and_cards above, where the invariants were debug_asserts that vanished in release builds.

Payload is now a read-only view onto card-yaml storage. The read half is untouched — get, is_fill, iter, keys, len, is_empty, contains_key, items, to_index_map, quill, kind, ext, seed — and card.payload() still returns it. What went crate-internal:

0.100 0.101
payload.insert(k, v) / insert_fill / remove card.store_field(name, value), or quill.writer(&mut doc).set(addr, value)
payload.set_quill(r) doc.set_quill_ref(r)
payload.set_ext(m) / take_ext card.store_ext(m)
payload.set_seed(m) card.store_seed_namespace(kind, overlay)
payload.set_kind(k) Card::new(kind, …) / doc.push_card(…)
payload.items_mut() no replacement — the invariants it could not enforce are the reason
Payload::new / ::default / ::from_index_map / ::from_items Document::new + the writer; Card::new for a composable card
card.payload_mut() the verbs above
Card::from_parts(payload, body) Card::new; TryFrom<StoredDocument> for stored data

Payload::take_seed had no caller anywhere in the tree and is deleted rather than hidden. Card::from_parts follows the mutators because it takes a Payload by value: with nothing to build one from, it was a constructor no caller could feed.

These are the doors PROGRAMMATIC.md already named, and the ones the bindings already used — no wasm or Python surface reached the mutation half.

A live session is born bound to its quill

LiveSession::apply took compiled plate JSON and documented an obligation it had no way to check: that the data came from the same schema pipeline as the session's first compile, and from the same quill. It held no quill, and compiled data does not carry the $quill reference, so applying one quill's data to another quill's session type-checked and rendered something undefined. Every caller re-assembled the missing half by hand.

The session now holds the QuillConfig it was opened against, and apply takes the document:

// 0.100
quill.check_quill_reference(&doc)?;
let data = quill.compile_data(&doc)?;
session.apply(&data)?;

// 0.101
session.apply(&doc)?;

The check and the compile happen inside, through QuillConfig::compile_checked — the same door Quillmark::open uses for the first compile, so the pairing cannot be enforced at one and skipped at the other. A mismatch errors with the quill::name_mismatch / quill::version_mismatch it always carried, before anything reaches the backend, and leaves the last-good compile serving reads like any other failed apply.

That makes the two check_quill_reference methods crate-internal: the sequence they existed to let a caller assemble now has one owner. compile_checked is the replacement where you want plate data for a render, and Quill forwards it beside compile_data and dry_run. compile_data stays public and unchecked for the plate-only use it serves (the CLI's --output-data), where no render follows.

If what you wanted was the pairing alone — routing a document to the quill it belongs to, without rendering it — none of those is a drop-in. dry_run checks the pairing but also coerces and validates, so an incomplete document that names the right quill fails it, and reading that as a mismatch is wrong. Either match the codes (quill::name_mismatch / quill::version_mismatch, which only the pairing emits) or compare the reference yourself:

let r = doc.quill_reference();
let belongs = r.name.as_str() == quill.config().name
    && Version::from_str(&quill.config().version).is_ok_and(|v| r.selector.matches(v));

Backends are unaffected in spirit but not in signature: LiveSession::new takes the config alongside the handle. It is #[doc(hidden)], and Backend is sealed and documented as unsupported to implement outside the workspace, so neither is under the compatibility promise — and each backend already has the &Quill in hand at open.

The WASM surface does not change. LiveSession.apply(doc) already took a document there, backed by a private config clone and a compile helper that this release deletes in favour of the core one.

The change bundle is a struct, and it carries islands

An island's payload — a table's cells, an image's url — was reachable from no op channel: text carries one slot char per island, lines the Island kind, marks neither. Editing one meant install, which is whole-field value semantics, so a table edit dropped every identity anchor in the field, including anchors on prose the edit never touched. IslandOp is the channel that closes that, on the same argument LineOp::SetContinues already answered for a hard break.

// 0.100
content.apply_field_change(&delta, &line_ops, &mark_ops)?;
card.apply_field_richtext_change("intro", &delta, &[], &mark_ops)?;

// 0.101
content.apply_field_change(&ChangeBundle { delta, line_ops, mark_ops, ..Default::default() })?;
card.apply_field_richtext_change("intro", &ChangeBundle { delta, mark_ops, ..Default::default() })?;

ChangeBundle is Default, so a call names only the channels it uses and a fifth channel would be additive at every call site — the reason it is a struct rather than a fourth argument. change_bundle_from_value returns the same struct in place of its (Delta, Vec<LineOp>, Vec<MarkOp>) tuple.

Two ops carry the island cases. IslandOp::Set { island } replaces the entry the island's id names, in place: props, type and loss all come from the op, nothing re-derives loss from the new props, and an id no island carries is ApplyError::UnknownIslandId rather than a silent no-op. IslandOp::Insert { at, island } places the slot and its backing entry together, so the orphan slot the text channel rejects (ApplyError::IslandSlotInInsert) cannot be built here; its id is caller-supplied, non-empty, and unused (EmptyIslandId, IslandIdCollision). Deleting an island still needs no op: a delta that removes its slot drops it.

Stage order is now delta → island ops → line ops → mark ops, and it is a coordinate contract. Island ops precede line ops so LineOp::SetKind { kind: Island } can validate against a line that already carries the slot; mark ranges stay in final-text coordinates, which now include any inserted slot. A block island is therefore one bundle of three channels: the delta's \n opens the line, the island op fills it, SetKind tags it. LineOp::Split cannot open that line, since line ops run after island ops.

// The same edit from the WASM surface.
doc.applyChange({}, {
  delta: { ops: [{ retain: 5 }, { insert: '\n' }] },
  islandOps: [{ op: 'insert', at: 6, id: 'isl-7', type: 'image', loss: 'lossless',
                props: { url: 'ex.com/a.png', alt: 'a' } }],
  lineOps: [{ op: 'setKind', line: 1, kind: 'island' }],
})

An editor that minted island ids now needs them to satisfy the never-ambient rule (DOCUMENT_STORAGE.md § Island-id determinism): continue the positional isl-{n} sequence past the field's highest, never a UUID or a clock reading.

The quillmark facade covers authoring, reading, and preview (additive)

quillmark re-exported enough to construct a quill and render, but not to author: Document::new took a QuillReference that could not be named, quill.writer(&mut doc) returned a TypedWriter that could not be named, and the errors of the writer verbs and of Quill::parse were likewise unspellable. An example importing only quillmark::* did not compile.

The read and preview sides had the same defect for the same reason — the gate covered the authoring flow alone. quill.reader(&doc) returned a TypedReader whose get yielded a ReadValue and whose card(i) yielded a CardReader; session.regions() and field_boxes() returned a RenderedRegion, and position_at() a ContentHit carrying a HitGranularity. All public, all reachable, none nameable. Eleven names join the list:

pub use quillmark_core::{
    BoundParseError, CardReader, ContentHit, EditError, HitGranularity, QuillReference,
    QuillValue, ReadValue, RenderedRegion, TypedReader, TypedWriter,
};

Nothing is removed, so no action is required — a consumer that added quillmark-core to Cargo.toml only to name these can drop it.

tests/facade_surface.rs now exercises the typed read and the preview queries alongside authoring and bound parse, annotating each return type explicitly so an inferred binding cannot let a name fall off the list unnoticed.

Two things stay out deliberately. SessionHandle is what a LiveSession is built from, but Backend is sealed and implementing one outside the workspace is unsupported by design, so a facade consumer obtains a session from Quillmark::open rather than constructing one. DocPath / DocSeg and the regions_to_doc_path / plate_addr_to_doc_path / doc_path_to_plate_addr free functions have wasm and Python callers, but those bindings depend on quillmark-core directly; whether a facade consumer needs them is a question no current flow answers.