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:
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.