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::Lossless → Loss::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::Lossless→Loss::LOSSLESS, and likewiseDEGRADED/UNREPRESENTABLE. Lossno longer derivesCopy, and no longer matches. Alet l = island.loss;off a borrow moves or needs.clone(); amatch island.loss { … }becomes a match onisland.loss.fidelity().Fidelityis 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: