Skip to content

0.98 → 0.99 — the open sets get a membership question, and a strict write lane

Stored blobs are unaffected: every 0.98 document still loads byte-identically, and 0.99 writes the same bytes for the same content. The behavioral break is on the write lane, where a shape 0.98 accepted and silently emptied is now a diagnostic. One decode path also changes — an unrecognized island loss class is carried rather than rewritten — but 0.98 normalized those away on the way in, so no blob it wrote can hold one.

Break Surface Action
attrs beside a built-in discriminator rejected on write TypeScript (install / applyChange), Rust (quillmark-content op wire) Emit an unknown under a name this build does not use
An opaque payload nesting past 128 levels rejected on decode TypeScript (install / applyChange; a backstop under the existing 100-level cap on makeCard / insertCard), Rust (serial::from_canonical_value, the op wire) Nothing — no storable blob reaches the limit
An unknown's attrs emitted in caller key order, not sorted Rust (serial::{mark,line_kind,container}_to_value) Nothing — canonical content bytes are unchanged
Two YAML entry points return YamlError, not a serde-saphyr type Rust (QuillValue::from_yaml_str, QuillConfig::schema_yaml) Name quillmark_core::YamlError; drop any direct serde-saphyr dependency
Most public enums become #[non_exhaustive]; OutputFormat::ALL becomes a slice Rust (quillmark-core, quillmark-content, quillmark-pdf, quillmark-pdfform) Add a _ arm where you match one; iterate ALL by reference
Most public structs become #[non_exhaustive] Rust (same crates) Build through new + the with_* setters instead of a struct literal
Loss becomes an opaque string wrapper and no longer derives Copy Rust (quillmark-content) Loss::LosslessLoss::LOSSLESS; clone or borrow where a copy was implicit; read fidelity through Loss::fidelity
An unrecognized island loss class round-trips verbatim instead of decoding to unrepresentable Storage (all surfaces) Nothing — no 0.98-written blob carries one
RenderOptions { .., ..Default::default() } no longer compiles Rust (every render caller) RenderOptions::default().with_output_format(fmt)
Backend is sealed Rust (backend implementors only) None — implementing it outside the workspace was already unsupported
Content::RESERVED_{MARK_TYPES,LINE_KINDS,CONTAINERS} become slices Rust (quillmark-content) Name the slice type; iterate by reference
A Quill/Document from a second copy of @quillmark/wasm is refused everywhere TypeScript (Engine, the writer/reader binds, equals/validate/resolve) npm ls @quillmark/wasm and dedupe to one copy

New, no action required: isUnknownLine / isUnknownContainer / isUnknownMark / isUnknownIsland on the WASM surface, and ContentLineKind re-exported from the package entry point; on the Python surface, doc.card(i) / doc.card_index_by_id(id) / doc.seed_overlay(kind) — the single-card, $id, and seed reads WASM already had.

quillmark also ships py.typed and stubs now, so mypy and Pyright see real signatures where the whole surface used to resolve to Any. Nothing at runtime changed, but a codebase that type-checked clean against Any may newly report errors — each one a call the binding would already have rejected.

The reserved-name rule reaches the wire

0.98 stated that an unknown may not reuse a built-in's name — {kind: "para", attrs: {…}} would serialize as the built-in and parse back as one, dropping the attrs — and enforced it in Content::validate. That check could not fire for anything arriving over the wire: a decoder resolves "para" to the built-in before the Unknown fallthrough, so the offending value never becomes an Unknown for validate to object to. The payload was dropped in silence (#1084).

0.99 rejects the shape where a host authors it:

// 0.98: accepted, stored as { kind: 'para' } — the attrs are gone, silently
doc.applyChange(addr, { lineOps: [{ op: 'setKind', line: 0, kind: 'para', attrs: { tone: 'warn' } }] });

// 0.99: throws — "content json shape: attrs beside built-in kind"

The rule is the same on all four axes and reads the same way each time: attrs next to a name this build has built in. It applies to applyChange's line and mark ops and to install (whose scan covers line kinds, containers, prose marks, and table-cell marks — every axis Content::validate checks). In Rust the strict lane is reached through serial::from_authored_value for whole content and ops::{mark_op_from_value, line_op_from_value, change_bundle_from_value} for the op wire; the per-axis strict readers behind them are crate-private. The lenient serial::{mark,line_kind,container}_from_value readers keep their behavior and their names.

Reading a document never got stricter. Content::from_canonical_json still accepts {kind: "callout", attrs: {…}} after callout becomes a built-in, because that blob is a document from before the promotion and must keep opening. Only a host writing the shape now is told, since only there does it mean a stale copy of the built-in list rather than a document from the past.

If you hit this, you emitted an unknown under a name that is no longer unknown. The fix is the next section.

Payload depth is bounded on the Value lane

0.98 capped container nesting (Invariant::NestingTooDeep) and left the payload axis open. An island's props and an unknown's attrs are opaque host JSON, and every consumer of one recurses a frame per level: key canonicalization, the content-hash key, and serde_json::Value's own Drop. Content::from_canonical_json is bounded by its parser — serde_json::from_str refuses past 128 — but the Value lane, the host-authored one, was not:

// 0.98: aborts the WASM module — a stack-overflow trap, not a catchable error
let v = []; for (let i = 0; i < 5000; i++) v = [v];
doc.install(addr, { , islands: [{ id: 'i1', type: 'widget', loss: 'lossless', props: v }] });

// 0.99: throws — "install: value nests deeper than 128 levels"

MAX_JSON_DEPTH is 128, the limit the string lane already enforces, so nothing a stored blob can carry is refused and no stored population is affected. The bag is checked where it is read off the wire, before it is cloned into the model — the clone spends the frames too, and so does dropping the result. Content::validate restates it as Invariant::JsonTooDeep for content that never went through a decoder. On the WASM surface the check sits on the JS side of the boundary, because serde_wasm_bindgen recurses while building the value and would trap before any Rust guard could run.

Every door that takes opaque host JSON carries the guard, not just the content one: install and applyChange for an island's props and an unknown's attrs, makeCard for a field value, insertCard for a payload item's value. Each throws "<call>: value nests deeper than 128 levels".

On the two card doors that limit is a backstop, not the bound you will meet. A card field is already capped at MAX_YAML_DEPTH (100) by Card::try_from, which names the offending field: "makeCard: field \tree` nests deeper than the maximum of 100 levels"`. That check runs in Rust, so it only reports on a value shallow enough to have survived the crossing; the 128 guard exists for the values that would not have, and nothing between 101 and 128 changes.

A quillmark-content dependent matching exhaustively on Invariant needs a JsonTooDeep arm; #[non_exhaustive] (also new in 0.99) makes that a wildcard.

Asking whether a value is known

0.98's guards each answer "is this arm X"isHeadingLine, isCodeLine, isListItemContainer, isTableIsland, isImageIsland, isLinkMark, isAnchorMark. Nothing answered "is this a value this build knows?", so a consumer that had to branch known-vs-unknown enumerated the built-in names in its own source — which is the closed-set coupling the open set was introduced to remove, and goes wrong at the first release that adds a built-in.

import { isUnknownLine, isUnknownContainer, isUnknownMark, isUnknownIsland }
  from '@quillmark/wasm/runtime';

// 0.98 — correct today, stale at the next built-in
const unknown = !['para', 'heading', 'code', 'island', 'rule'].includes(line.kind);

// 0.99
if (isUnknownLine(line)) carry(line.kind, line.attrs);

Each narrows to the open arm, so attrs (props on an island) is reachable in the true branch. The known names stay upstream's business, which is the point of an open set.

loss becomes an open set, and Loss stops being an enum

0.98 opened four vocabularies (mark type, island type, line kind, container) and left an island's loss class closed. Its decoder mapped anything unrecognized to unrepresentable, so a reader that merely opened a document a later build wrote, then saved it, moved that document's content hash. 0.99 carries the class verbatim instead.

loss opens on the island type axis' terms, not the block axes': it carries no payload, so the wire string is the stored value and the closed set is a view over it. Loss is therefore an opaque string wrapper, not an enum with an Unknown arm:

// 0.98
pub enum Loss { Lossless, Degraded, Unrepresentable }

// 0.99
pub struct Loss(/* private */);
impl Loss {
    pub const LOSSLESS: Loss;
    pub const DEGRADED: Loss;
    pub const UNREPRESENTABLE: Loss;
    pub fn new(class: &str) -> Loss;
    pub fn as_str(&self) -> &str;
    pub fn fidelity(&self) -> Fidelity;
}

Three consequences for a Rust consumer:

  • The variants become associated consts. Loss::LosslessLoss::LOSSLESS, and likewise DEGRADED / UNREPRESENTABLE.
  • Loss no longer derives Copy, and no longer matches. A let l = island.loss; off a borrow moves or needs .clone(); a match island.loss { … } becomes a match on island.loss.fidelity().
  • Fidelity is the new closed view, with the three levels 0.98's enum had. It is exhaustive, so a match on it needs no _ arm and a future level is a major bump rather than a silent gap.

Read fidelity through Loss::fidelity, never by comparing against Loss::LOSSLESS: a class this build cannot interpret degrades to Fidelity::Unrepresentable, so nothing is ever claimed to carry faithfully on the strength of a name this build cannot read.

An unrecognized class is reachable only from a document a future build wrote: 0.98 flattened one on the way in, so nothing it wrote can carry one.

There is no reserved-name rule on this axis and none is needed. The block axes guard one (Invariant::ReservedUnknownTag and its two siblings) because their Unknown { tag, attrs } arm gives a built-in's name a second spelling that serializes to the built-in and parses back as it, dropping the attrs. Loss has one value per wire string, so Loss::new("lossless") == Loss::LOSSLESS and the collision is unspellable.

Render-only and read-modify-write read the guide differently

0.98's advice — treat unknown lines as paragraphs, unknown containers as absent — is right for a render-only consumer and destructive for one that writes back. An editor lowers a whole-field diff, restating every line's kind and containers whenever any of them changed, so a construct its tree cannot hold is gone on the next keystroke: the document opens intact and saves mangled.

A read-modify-write consumer carries unknowns inertly instead — a carrier per axis (an unknown mark, an unknown attribute on the paragraph, an unknown_container node) that renders as the nearest safe neighbor and re-emits the tag and attrs verbatim. isUnknown* is how it decides what to carry.

The bound, stated once: the carrier preserves unknown tags, not unknown payloads on known tags. A future kind: "footnote" carrying a sibling ref loses ref at any consumer that predates it, guards or no. That is the other end of 0.98's payload rides attrs rule, and it is why the write lane now objects rather than dropping.

One copy of @quillmark/wasm, said out loud

Two copies of the package in one node_modules tree are two core builds: two WASM linear memories, two Quill/Document classes. 0.98 half-worked there. Engine was duck-typed, so a quill from copy A rendered on an engine from copy B, while Document.equals, Quill.validate, Quill.resolve and the typed writer met wasm-bindgen's generated _assertClass and threw a bare Error reading expected instance of Document at a value that is a Document — outside the package's own error contract, naming neither the cause nor the cure.

0.99 refuses the crossing everywhere and says why:

// 0.98: rendered fine, at a per-copy quill clone cache nobody could see
// 0.99: QuillmarkError — "the Quill belongs to a different copy of @quillmark/wasm"
await engine.render(quillFromTheOtherCopy, doc);

The check covers Engine (render, open, supportedFormats, supportsCanvas), LiveSession.apply, the writer and reader binds, and the three by-reference core methods. Every rejection is a QuillmarkError that isQuillmarkError narrows, coded runtime::foreign_handle, hinting npm ls @quillmark/wasm. A value that is not a handle at all keeps its own diagnostic (runtime::not_a_document / runtime::not_a_quill) — a caller who passed null has a different bug and npm ls is the wrong advice.

Nothing changes for a correct install, which is every install with one copy. Errors are the deliberate exception to the rule: isQuillmarkError stays structural and still narrows an error from any copy, an error being data rather than a handle.

ContentLineKind is nameable

ContentLineKind — the line-kind half ContentLine and setKind share — was declared in the generated .d.ts but not re-exported from the package entry point, and package.json's exports map exposes only ".". It is now exported beside ContentLine and LineOp. No runtime change.

That makes the whole-lift spelling of a setKind op type-check without a cast:

function kindPart(line: ContentLine): ContentLineKind {
  const { containers, continues, ...kind } = line;
  return kind;
}
const op: LineOp = { op: 'setKind', line, ...kindPart(line) };

The alternative — an arm-by-arm switch rebuilding the payload — means guessing at the open arm's shape and re-editing on every arm added upstream.

Op-wire key order follows the caller

0.98 sorted each opaque payload bag three times on the way to canonical JSON — once in normalize, once per encoder, once in the terminal sort_keys_owned. 0.99 drops the per-encoder pass, so mark_to_value, line_kind_to_value, and container_to_value emit an unknown's attrs in the caller's key order rather than sorted.

Canonical content bytes are unchanged — the terminal sort still runs, and to_canonical_json is byte-identical for every document. The difference is visible only to a Rust caller that encodes an op through those three functions directly and compares the JSON as bytes. Nothing hashes the op wire; if a consumer does, sort at its own top level.

The YAML engine leaves the public API

QuillValue::from_yaml_str and QuillConfig::schema_yaml returned serde_saphyr::Error and serde_saphyr::ser::Error. Both now return quillmark_core::YamlError.

// 0.98 — naming the error meant depending on serde-saphyr at our exact pin
let v: Result<QuillValue, serde_saphyr::Error> = QuillValue::from_yaml_str(src);

// 0.99
let v: Result<QuillValue, quillmark_core::YamlError> = QuillValue::from_yaml_str(src);

YamlError carries message(), hint(), and optional 1-indexed line() / column(), and renders to a diagnostic via to_diagnostic(code, file) — more than the old types gave a caller unwilling to match on the engine's own variants. Code that only propagated the error with ? into a Box<dyn Error> needs no change.

The message is also sanitized. serde-saphyr appends its own Rust API names to some errors (set DuplicateKeyPolicy in Options, use from_multiple); those reached callers verbatim before and are stripped now, so a Quill.yaml parse failure reads the same on the CLI, in Python, and in JS.

serde-saphyr's version series is 0.0.x, where Cargo treats every release as incompatible with every other. While its error types sat in our signatures, any serde-saphyr release — a bugfix included — was a breaking change to quillmark-core. It no longer is, and the engine is now free to move.

from_yaml_str also gains the depth budget the other YAML entry points already carried: a document nesting past MAX_YAML_DEPTH (100) is now a YamlError rather than a stack overflow in the parser.

The public API opens ahead of 1.0.0

Nothing in the workspace carried #[non_exhaustive] before 0.99; the sweep lands in one release, across every published crate.

In quillmark-core: ParseError, EditError, ValidationError, StorageError, WireError, CoercionError, CardKindError, RichtextDecodeError, OutputFormat, FieldType, FieldSource, FieldViolation, PayloadItem, PayloadItemWire, PathStepWire, MetaKey, ReadValue, DocSeg, PathSegment, VersionSelector, HitGranularity, and StoredDocument; in pdfform, WidgetKind / FormParseError / BindError.

In quillmark-content — the crate whose open sets 0.98 introduced, so the one most likely to hold an exhaustive match written against them: LineKind, Container, MarkKind, Invariant, LineKindMismatch, ParseError, ImportError, ApplyError, Op, Assoc, LineOp, and MarkOp. Five of them — ApplyError, Assoc, LineOp, MarkOp, Op — are re-exported from quillmark_core::session, so a match reached through core needs the arm too.

0.98 asked for an Unknown arm on LineKind and Container; 0.99 asks for a _ beside it, and that one keeps compiling when the next member lands.

Matching one from outside its crate now needs a _ arm:

match err {
    ParseError::EmptyInput(msg) => ,
    ParseError::MissingQuill(msg) => ,
    _ => ,                                  // new in 0.99
}

This is the point of 1.0.0: after the tag, a new variant on any of these would otherwise be a major release. Diagnostics were already the recommended way to route a failure (prose/canon/ERROR.md — identity is the namespaced code, never the variant), so most code needs no change.

Severity and FileTreeNode are open too. A _ arm over Severity has a safe direction — escalate to Error, which over-reports rather than hides a fatal. FileTreeNode is only ever built from outside the crate, and the attribute leaves variant construction untouched, so Quill::from_tree is unaffected.

Four stay exhaustive, each saying so in its rustdoc. The V0_92_0 storage DTOs are frozen by definition: a shipped schema version never changes. The other three are the rule's real exceptions, all one shape: an out-of-crate _ arm would be silently wrong. quillmark_pdf::FieldType is dispatched over whole by pdfform's value resolver and its content-stream flattener, and a variant they miss draws nothing on the page and reports nothing. KnownIslandType is dispatched over by the Typst emitter, where a missed type drops the island out of the projection. Fidelity is a ladder a consumer reads to decide what to warn about, and it has no safe rung to fall through to. For all three the compile error is the guardrail, and adding a member is a major bump on purpose.

The public structs open too

A struct with #[non_exhaustive] cannot be built from a struct literal outside its crate — functional update included. Reading and assigning its fields still works; only the literal is gone.

The types you construct: Artifact, RenderedRegion, ContentHit, ChangeSet, Diagnostic, RenderResult, Location, CardWire, CardSchema, FieldSchema, QuillConfig, RenderOptions, and quillmark-pdf's FieldSpec and StampOptions. The rest of the sweep lands on types only ever handed back to you — Resolved*, FormSpec, BoundField, Parsed — where the attribute costs a caller nothing.

Each has a new taking the fields it always carries, and a with_* setter for each optional one (StampOptions starts from default()):

// 0.98
let region = RenderedRegion { field, page, rect, span: Some([12, 34]) };

// 0.99
let region = RenderedRegion::new(field, page, rect).with_span([12, 34]);

A field you already hold as an Option goes in by assignment, which needs no setter:

let mut region = RenderedRegion::new(field, page, rect);
region.span = span;                          // span: Option<[usize; 2]>

RenderOptions is the one that touches every caller

// 0.98
&RenderOptions {
    output_format: Some(OutputFormat::Pdf),
    ..Default::default()
}

// 0.99
&RenderOptions::default().with_output_format(OutputFormat::Pdf)

Setters exist for all five options: with_output_format, with_ppi, with_pages, with_producer, with_regions. Assignment works too, and is the shorter path when you already hold an Option:

let mut opts = RenderOptions::default().with_output_format(format);
opts.ppi = ppi;                              // ppi: Option<f32>

This is the break the 1.0.0 freeze is for. RenderOptions gained producer and regions inside six minors; leaving it open would have made the seventh option a major release, and a tag is the last point where the edit is one line per call site.

Backend is sealed

quillmark_core::Backend gains a #[doc(hidden)] sealed supertrait. Its rustdoc already called out-of-workspace implementation unsupported — open returns a LiveSession only a #[doc(hidden)] SessionHandle can build — and nothing enforced it. Now the trait says so in the type system, which is what makes a later method addition a minor rather than a major. No caller of a backend is affected; only an implementor of one.

OutputFormat::ALL is a slice

// 0.98
pub const ALL: [OutputFormat; 3];
for fmt in OutputFormat::ALL {  }

// 0.99
pub const ALL: &'static [OutputFormat];
for &fmt in OutputFormat::ALL {  }

An array's length is part of its type, so a fourth format would have broken every caller naming it — #[non_exhaustive] on the enum alone would not have helped.

quillmark-content's three known-name lists take the same shape for the same reason: Content::RESERVED_MARK_TYPES, RESERVED_LINE_KINDS, and RESERVED_CONTAINERS are &'static [&'static str]. Each names the known half of an open set, so it grows whenever a construct is promoted into the projection — the motion the open sets were introduced to make routine. Pinning a list's length in its type made that routine change a major.

.contains(…) and .iter() read the same. A for loop over one now binds &&str, and a signature naming the type takes the slice:

// 0.98
fn reserved() -> [&'static str; 5] { Content::RESERVED_LINE_KINDS }

// 0.99
fn reserved() -> &'static [&'static str] { Content::RESERVED_LINE_KINDS }