Skip to content

0.101 → 0.102 — the pre-1.0 vocabulary reset

Three independent changes this step. The largest is a rename sweep: verbs, diagnostic codes, and two words that meant different things at different altitudes. Documents and stored blobs are untouched — no document reparses, no blob remigrates, and the storage wire format is byte-identical — but nearly every consumer touches at least one renamed verb, and diagnostic codes are the part that cannot wait: consumers route on them, so they are settled before 1.0 freezes them. The second is a producer contract for island-id restore. The third is WASM-only: @quillmark/wasm ships --target web, and await init() becomes mandatory — in exchange, the bundler plugins it required are gone.

Nothing here changes behavior except two cases, both called out below: a splice against an absent field, and the receipt reviseBody now returns.

Break Surface Action
install*overwrite* Rust, WASM Rename; the ladder is now spelled by anchor fate
writer setBodyreviseBody, now returning Delta Rust, WASM, Python Rename; discard the receipt if unused
applyFieldRichtextChangeapplyFieldChange Rust Rename
getMarkdown, reader getBodybodyMarkdown Rust, WASM, Python Rename
LiveSession.applyupdate, and backend::apply_unsupportedbackend::update_unsupported Rust, WASM Rename both; re-key any routing on the code
store/removeSeedNamespacestore/removeSeedOverlay Rust, WASM, Python Rename
schemaVersionOf / currentSchemaVersionstorageVersionOf / currentStorageVersion Rust, WASM, Python Rename; the wire key stays "schema"
edit::field_richtext_decodeedit::field_decode, +codec arg every surface Re-key routing; absence no longer raises it
edit::field_conformedit::field_coercion_failed every surface Re-key routing
edit::field_richtext_not_inlineedit::field_not_inline, +codec arg every surface Re-key routing; now covers plaintext(inline) too
richtext::not_inlinevalidation::not_inline, plaintext::not_plainvalidation::not_plain every surface Re-key routing
Card::commit_field / Card::revise_field_checked become #[doc(hidden)] Rust Write through quill.writer(&mut doc)
EditError::variant_name is removed Rust Use EditError::code(), or match the enum
Island-id minting splits by case WASM, Rust (islandOps producers) Re-insert the held Island verbatim on restore
await init() is required before first use WASM Add one line at startup; drop vite-plugin-wasm and vite-plugin-top-level-await

Verbs that asserted the opposite of what they did

install destroyed identity; every other verb in its group did not. The content lane is a ladder, and the rung a verb sits on is decided by what happens to the identity anchors already on the value. Grouping the three as "identity-aware" hid the only distinction that matters, and install named the one that keeps nothing. It was also a homograph with package installation, a noun the same WASM runtime uses in its own error strings.

// 0.101
doc.install(addr, importMarkdown(md))

// 0.102 — same call, and the name now says the anchors are gone
doc.overwrite(addr, importMarkdown(md))
Rung Anchors Rust WASM
overwrite destroyed overwrite_body / overwrite_field doc.overwrite(addr, rt)
revise rebased revise_body / revise_field doc.revise(addr, md)
apply preserved apply_body_change / apply_field_change doc.applyChange(addr, bundle)

The writer's setBody was the content lane's reviseBody with the receipt thrown away. A body carries no field schema, so a typed-lane verb sat on an operation with nothing to type. It keeps its place on the writer — that is where a caller already is — but it now spells what it does and hands back the Delta it was already computing.

// 0.101
writer.setBody(md)

// 0.102 — same write; the receipt was always there, now it is returned
const delta = writer.reviseBody(md)     // JS
let _ = writer.revise_body(md)?;        // Rust, discarding
w.revise_body(md)                       # Python, receipt discarded as on revise_field

applyFieldRichtextChange promised a codec it never had. It is schema-blind and assumes markdown, exactly like its neighbour revise_field, which never carried richtext in its name. Now applyFieldChange.

The markdown body projection had three names. getMarkdown (WASM), reader.getBody, and core's Card::body_markdown. One name now, on every surface: bodyMarkdown / body_markdown. WASM loses its get* read convention here, which is the deliberate cost of the parity rule that a binding verb be identical to its core twin.

LiveSession.apply was an op splice and a whole-document recompile at once. The session verb is update; the content lane keeps applyChange. The diagnostic code moved with it — a public code must not name an operation the surface no longer has:

// 0.101
session.apply(doc)          // and: err.diagnostics[0].code === 'backend::apply_unsupported'

// 0.102
session.update(doc)         // and: 'backend::update_unsupported'

The Rust trait seam (SessionHandle::apply) and the feature-gated raw-plate seam (LiveSession::apply_data) renamed alongside. An out-of-workspace backend implementing SessionHandle renames one method.

Two words that meant two things

namespace was a free-form consumer key and a card kind. $ext is keyed by a namespace its consumer owns and the engine never inspects. $seed is keyed by a validated composable card-kind, and its value is an overlay — which is already what the read (seedOverlay) and the type (SeedOverlay) called it. Only the $seed pair renames; $ext's namespace verbs are correct as they stand.

# 0.101
doc.store_seed_namespace("indorsement", {...})

# 0.102
doc.store_seed_overlay("indorsement", {...})

schema was a quill's field declarations and the storage DTO's wire version. The accessors now say storage:

Document.storageVersionOf(blob)      // was schemaVersionOf
Document.currentStorageVersion()     // was currentSchemaVersion

Rust renames with them (peek_storage_version, STORAGE_V0_93_0), because the parity rule that keeps binding verbs identical to their core twins is what makes the table reviewable.

The wire key stays spelled "schema". It is the serde tag the storage DTO dispatches on, so retagging it would break the versioning mechanism the tag exists to serve — and it is not where the confusion originated. Stored blobs are byte-identical across this step. The two senses part at the API, where a quill's field declarations are the only schema.

The diagnostic taxonomy

This is the part with a deadline. Codes are a public contract consumers route on, so they are corrected now or carried past 1.0.

0.101 0.102 Why
edit::field_richtext_decode edit::field_decode + codec arg Raised for plaintext failures under a richtext name
edit::field_conform edit::field_coercion_failed conform::field_conform stuttered, and "conform" named both the repair pass and a failure class
edit::field_richtext_not_inline edit::field_not_inline + codec arg plaintext(inline) fell through to the generic code
richtext::not_inline validation::not_inline Raised for plaintext violations too
plaintext::not_plain validation::not_plain Moved with its sibling; stage-namespaced like every other code

The conform::* twins rename in lockstep (conform::field_decode, conform::field_coercion_failed, conform::field_not_inline).

Route on the codec, not on the code. field_decode and field_not_inline each carry a codec arg ("richtext" or "plaintext"), so one code covers both lanes and the arg says which ran:

// 0.101 — two codes for one condition, and one of them lied
if (code === 'edit::field_richtext_not_inline') { ... }   // plaintext never got here

// 0.102 — one code, both lanes
if (code === 'edit::field_not_inline') {
  const codec = err.diagnostics[0].args.codec   // 'richtext' | 'plaintext'
}

field_coercion_failed, not field_type_mismatch. The variant is minted from the same CoercionError as validation::coercion_failed and carries the same target key; validation::type_mismatch is a stricter, differently-shaped condition that never attempts coercion. The name points at the twin it has.

EditError::variant_name is removed. It returned the bare variant name ("FieldConform"), a second discriminator that had to be kept in lockstep with code() and that nothing routed on: both binding error mappers stamp code() onto the Diagnostic they raise, and ERROR.md has said identity is the code since the edit::* family landed. Assertions and matches move one of two ways:

// 0.101
assert_eq!(err.variant_name(), "FieldConform");

// 0.102 — the stable contract, and the thing a consumer sees
assert_eq!(err.code(), "edit::field_coercion_failed");
// or, in Rust, match the enum (`#[non_exhaustive]`, so keep a `_` arm)
matches!(err, EditError::FieldCoercionFailed { .. })

Absence stops being a decode error. apply_field_change on an absent field reported FieldRichtextDecode with the message "field is absent", the one place in the API where a missing field was not simply None. It now splices against the empty content, exactly as revise_field diffs against it:

// 0.101: Err(FieldRichtextDecode { message: "field is absent" })
// 0.102: Ok(()) — the field is created, as revise_field would create it
card.apply_field_change("intro", &bundle)?;

This is the one behavior change in the sweep, and it does not lose you an error. A bundle that expected content still fails, and now reports what it actually hit: the text delta declares the base length it was computed against, so a stale splice against a vanished field lands as edit::content_apply rather than as a decode error standing in for absence. Only a bundle whose base length is zero succeeds — and a zero-base bundle is one the producer computed against an empty field, which is what an absent field is. A consumer that was routing on edit::field_richtext_decode to detect a vanished field should route on edit::content_apply, or check presence before splicing.

The typed primitives leave the documented surface

Card::commit_field and Card::revise_field_checked are #[doc(hidden)]. They were disambiguated from their opaque and schema-blind neighbours neither by receiver nor by verb but by taking a schema argument — an undocumented third mechanism, and the reason one verb appeared to name two lanes. The writer is the typed door on every other surface; core no longer differs.

// 0.101
card.commit_field("qty", 3, &schema)?;

// 0.102 — the quill resolves the schema, which it always could
quill.writer(&mut doc).set("qty", 3)?;

They remain callable (the in-workspace fuzz harness drives the coercion seam directly), on the same terms as the other hidden items: no stability promise.

Restoring a deleted island re-lands its original id

The rest of this guide is the second, unrelated change. It is a producer contract the 0.101 guide stated for one case and left readable as covering two. An editor that built its undo path from that line renames the island on restore, which moves the content hash of a document the user believes they restored.

The minting rule covers new islands only

0.101 closed with the never-ambient rule for editors that mint island ids: continue the positional isl-{n} sequence past the field's highest, never a UUID or a clock reading. That is correct for a new island and wrong for a restored one.

Deleting a slot drops the backing Island from the store, so undo re-inserts it through IslandOp::Insert, which carries a whole island and therefore an id. Minting a fresh one there is a rename: ids are hash input, so the restored document does not carry the bytes it had before the delete, and divergence detection and cache keys see a change the user did not make.

// The delete dropped the payload from the store, so the restore re-inserts the
// copy the producer kept: { id, type, props, loss }.
const held = undoRecord.island

// 0.101 — the mint rule read as covering every insert
doc.applyChange(path, {
  islandOps: [{ op: 'insert', at: 6, ...held, id: nextIslandId(field) }],
})

// 0.102 — a restore re-lands the id the drop freed
doc.applyChange(path, {
  islandOps: [{ op: 'insert', at: 6, ...held }],
})

A pasted copy of an island that is still live is a new island and mints fresh, since the original holds the id. Swapping the two cases is IslandIdCollision on the paste, or the silent rename on the restore.

The two rules compose because minting reads the live ids. Deleting the highest island frees its number for the next mint, so a later restore of that delete would collide, but linear undo never reaches the state: a mint made after a delete is undone before it. A producer with non-linear history (selective undo, a merge) owns the case, and the apply refuses it rather than aliasing two islands. DOCUMENT_STORAGE.md § Island-id determinism carries the rule.

Two contracts the island channel now states

Neither is a behavior change; both were reachable only by reading the implementation, and a producer that guessed wrong got a wrong document rather than an error.

at is sequenced. An island op's at counts the text the delta and this bundle's earlier island ops left, not the shared post-delta frame. Each insert splices its slot before the next op reads the text, so slots after a and b of abc go in at 1 and 3, and of two inserts at one position the later one lands first. A bundle carrying a single island op is unaffected, which is why this surfaces first on a paste of two tables.

A slot-bearing splice splits. A delta insert string may not carry an island slot, which would orphan. A producer that computes its edit as one diff over the whole field text carries slots in that string on any paste of an island or undo of a deletion; the splice splits into the slot-free delta plus one insert per slot. A block island's line demotes to para when its slot goes, so re-landing one re-tags the line as a third channel.

@quillmark/wasm requires await init()

This is the one break in the step that bites code which never called the renamed thing. init existed before as a no-op — the panic hook installed itself at instantiation, so calling it changed nothing and most consumers never did. It now performs instantiation, and nothing works until it resolves.

import { init, Quill, Document, Engine } from '@quillmark/wasm';

await init();                        // ← add this

const quill = Quill.fromTree(tree);  // unchanged, and still synchronous

Once, anywhere before first use. Extra calls are free — init memoizes its promise, so several entry points may each await it for one instantiation. Only the core build is yours to initialize; Engine still loads and instantiates a backend itself, on the first render against it.

Forgetting it is not subtle: reaching the surface early throws a QuillmarkError coded runtime::not_initialized naming the fix.

What you get back

Delete these from your bundler config:

import wasm from 'vite-plugin-wasm';                 // ← no longer needed
import topLevelAwait from 'vite-plugin-top-level-await';  // ← no longer needed

And delete the discipline that came with them. The package was built --target bundler, which emits import * as wasm from "./wasm_bg.wasm" — a form no browser and no bundler resolves natively. The plugin that fixed it rewrote the import into a top-level await, and because the runtime statically re-exports the core build, that await landed on the static module graph of everything importing @quillmark/wasm. Consumers had to keep the package off every route's static graph, transitively and forever, or a SvelteKit route would render blank in Safari's dev server while Chrome and vite build stayed green.

--target web emits neither form. A static import of @quillmark/wasm is now safe anywhere, SSR included, and scripts/build-wasm.sh asserts the built artifacts carry no .wasm import and no top-level await, so the constraint cannot come back unnoticed.

One line stays, for Vite's dev server only: dependency pre-bundling relocates the package away from its binary.

export default { optimizeDeps: { exclude: ['@quillmark/wasm'] } };

A load failure surfaces as runtime::init_failed, whose hint names that line.

Node

@quillmark/wasm now imports under plain Node, with no bundler and no flags — the ESM .wasm import needed --experimental-wasm-modules, so this never worked before. The call is the same argless await init(); the package resolves the byte source per environment through a subpath import, streaming from a URL in a browser and reading off disk under Node.

initSync is not exported

An embedded-bytes host that wants a fully synchronous startup loses it. The capability remains through init(source), which takes bytes, a Response, a WebAssembly.Module, or a URL — asynchronously. This is deliberate: a second way to instantiate is the ambiguity the explicit gate exists to remove. Pass the source on the first call; a later call with a different source throws runtime::init_conflict rather than silently ignoring it, and passing the same value again is fine, so several entry points may each await init(BYTES) against one constant.