Skip to content

0.114 → 0.115 — a matrix member mapping takes the ordinary ladder, an island stores its value and its line is a para, a matrix roster is flat, a schema is loaded, and validate compiles the plate

The break that needs the most care is the one nothing reports: a matrix member spelled as a mapping naming no held flips from ticked to unticked. The value is well-typed either way, so no type checker, no load error and no diagnostic fires — the page prints an unticked box, and that member's columns at their blanks, where it printed a ticked one with its answers. Find the form in your documents before upgrading; the section below says how.

The other break no type checker reports: canonical content bytes move. For any document holding an island the loss key leaves the island form; for any document holding a table {"kind":"island"} becomes {"kind":"para"}. A content hash or cache key computed over stored bytes recomputes on those rows. The storage tag becomes quillmark/document@0.115.0, and no stored row needs migrating: rows tagged @0.112.0, @0.93.0, @0.92.0, @0.82.0 and @0.81.0 migrate forward on read, and a card body written with either old spelling opens here and comes back in the new one. A field's content keeps the spelling it was stored in until that field is next written, and reads as content either way. No rendered output moves for either fact.

Break Surface Action
A matrix member spelled { detail: X } — a mapping naming no held — is unheld, where it was held document YAML, rendered output Spell { held: true, detail: X } on every member meant to be ticked. Nothing reports this one: grep for it
loss leaves an island's canonical form, which is {id, type, props} storage JSON, content hashes Re-baseline a byte snapshot or hash keyed on stored content. Rows carrying the key still open
A block island's line is {"kind":"para"}; the island kind retires storage JSON, content hashes Read the island backing the line's slot instead of the kind. Rows spelling island still open
A matrix member reaches the plate without group plate JSON Drop m.group from the plate; a plain m.group fails to compile on the missing key. A partition a page prints is separate fields
A blueprint's # when <MEMBER>: <fields> name line is gone; a dormant world is its own cells, commented out blueprint() output Read the block. Re-baseline anything asserting on blueprint bytes
quillmark validate compiles the plate CLI Fix the plate, or pass --no-render where the gate is meant to read the configuration alone
A matrix's members: is a mapping of id: Title, not a list of {group, values} blocks Quill.yaml Hoist each block's values: and drop its group:. Declaration order carries. The list form fails at load with quill::field_parse_error
A ui.layout: table column that is not a leaf is quill::table_column_not_flat Quill.yaml Flatten the column, or drop ui.layout: table from the field. A container column was already undrawable; the refusal is at load now instead of silent at the editor. Prose columns are unaffected whatever their inline
ContentIsland.loss and ContentLossClass are gone WASM / TypeScript Drop the field from island literals. A loss still spelled on an authored island or an islandOps entry is ignored, not refused
ContentLineKind loses its { kind: "island" } arm WASM / TypeScript Read the island. A setKind still naming island lands as para, so the break is at the type checker, not at run time
QuillMatrixGroup is deleted; QuillFieldSchema.members is a Record<string, string> WASM / TypeScript Read the mapping
Loss, Island::loss, Island::with_loss and IslandType::default_loss are deleted Rust Build an island from {id, island_type, props}; a struct literal or destructure of Island loses a field
LineKind::Island is deleted Rust An exhaustive match loses an arm; Content::block_island_at is the read that replaces it
StoredDocument gains V0_115_0, and STORAGE_V0_115_0 is the current tag Rust An exhaustive match over StoredDocument gains an arm; compare against STORAGE_V0_115_0 where STORAGE_V0_112_0 named the current tag
QuillConfig, CardSchema and FieldSchema no longer implement Deserialize Rust Load the quill the schema belongs to. The three still serialize, so schema(), schema_yaml() and every emission are unchanged
FieldSchema::from_quill_value is crate-internal Rust Same: load the quill. It parsed a field without the shape walk that follows it at load
MatrixGroup and MATRIX_GROUP_KEY are deleted; FieldType::Matrix carries an IndexMap<String, String> Rust Read the roster through FieldType::matrix_roster, which replaces matrix_members
island loss leaves the closed vocabularies, which are four storage JSON, both lanes None. A row spelling a class outside the built-ins opened as ParseError::UnknownName { axis: "island loss", .. } and opens plainly now
quill::duplicate_matrix_member retires diagnostics None. A mapping has one slot per key, so the duplicate the code named is unspellable
group is a column name a matrix may declare Quill.yaml None. quill::matrix_reserved_column names held and title

The loss key leaves an island

An island's canonical form is {id, type, props}.

Why the class goes rather than grows

One import minted a class other than lossless: a table cell holding an inline image, whose alt text lands as the cell's text and whose url lands nowhere. That is a fact about the import, not about the island it mints. A cell written | a cat | and a cell written | ![a cat](cat.png) | mint the same props, and the markdown projection has no syntax to tell them apart on the way back out — so the class survived neither a to_markdown → from_markdown hop nor re-derivation from the row that carried it, and the content fixed point had to be stated modulo it.

Nothing in the engine read it either: export dispatches on an island's type, the Typst emitter and the acroform binder never looked, and an islandOps set stored whatever class the op named. What is left is a per-island annotation that the content model guarded as an invariant it could not check. The guard goes with it: the closed vocabularies now cover the four discriminators a projection dispatches on (line kind, container, mark type, island type), and a row is no longer refused over a field no reader consumed.

A host that surfaced the annotation to a user surfaces the drop at the import instead, where the markdown it is about is still in hand. A row read later cannot say what its markdown once held.

The island line kind retires

A line whose sole content is an island's slot carried one of two kinds. A table's line was {"kind":"island"}, an image's was {"kind":"para"}, and which one a document held was never the line's to decide: markdown writes a table as a block and an image inline, so IslandType::block_only already settled it and Content::normalize already wrote the answer. The kind was a second copy of a fact the island beside it carried.

Now there is one copy. Every line holding a lone slot is a para:

{ "kind": "para", "containers": [] }

Whether that slot's markup is a block is read off the island — the same place the mint, the placement refusal (BlockIslandNotAlone) and both emitters were already reading it.

Branching on line.kind === 'island'

The TypeScript union ContentLineKind no longer carries the arm, so npm run typecheck reports the comparison rather than leaving it to run time. A host that built an editor node from the kind reads the island instead. The slot index is the number of slots in the text before the line:

const SLOT = ''

function blockIslandAt(body, i) {
  const segs = body.text.split('\n')
  if (segs[i] !== SLOT) return null
  const before = segs.slice(0, i).join('\n').split(SLOT).length - 1
  const island = body.islands[before]
  return island?.type === 'table' ? island : null
}

type === 'table' is the whole of block_only today. A host that would rather not track which types are block markup can read the projection instead: exportMarkdown still writes the table as its own block.

A field's content read with getStored is its stored bytes, typed unknown, so a table stored in a richtext field before 0.115 still hands back {"kind":"island"} and loss there until the field is next written. quill.conform rewrites such a field in the current spelling unless it carries a !must_fill, and reader.getContent answers in the current spelling either way. A host casting getStored to Content reads island as para.

{ op: 'setKind', kind: 'island' } is no longer a spelling

Landing a block island was three channels — the delta opening the line, the island op filling it, a setKind tagging it. It is two now, and the setKind goes:

// 0.114
doc.applyChange({}, {
  delta: { ops: [{ retain: 5 }, { insert: '\n' }] },
  islandOps: [{ op: 'insert', at: 6, id: 'isl-t', type: 'table', props }],
  lineOps: [{ op: 'setKind', line: 1, kind: 'island' }],
})
// 0.115
doc.applyChange({}, {
  delta: { ops: [{ retain: 5 }, { insert: '\n' }] },
  islandOps: [{ op: 'insert', at: 6, id: 'isl-t', type: 'table', props }],
})

The op wire is forgiving rather than strict here, unlike the attrs respelling of 0.112: a setKind still naming island lands as para, which is where it would have settled anyway. It is a type error, not a run-time one.

Rust: LineKind::Island is gone

An exhaustive match over LineKind stops compiling, and LineKind::takes_continuations is now necessary but not sufficient for "this line takes continuations" — a para holding a block island's slot is one line too. Content::block_island_at(line) is the read that answers it — the island backing that line's lone slot, where its markup is a block — and Content::normalize reads the same fact where it used to read the kind.

What moves without breaking in stored content

Canonical bytes, for any document holding an island (the loss key) and for every document holding a table (the line kind), so content hashes change: cache keys and template-divergence checks keyed on stored bytes recompute on the rows that carry one — for a card body when the row is next written, for a field's content when that field is.

The storage tag is now quillmark/document@0.115.0 (Document.currentStorageVersion). Rows tagged @0.112.0, @0.93.0, @0.92.0, @0.82.0 and @0.81.0 migrate forward on read; nothing else about the envelope changed, and the @0.112.0 hop is the body decode and nothing more.

Nothing else in content. Exported markdown, generated Typst, rendered output and every island's own props are unchanged for either fact: neither the class nor the kind was what an emitter dispatched an island on. A field whose content still carries either spelling projects to the markdown it did and takes a !must_fill as it did.

Why the decoder, not a migration

A migration can only reach the card body. Every other stored content sits inside an opaque payload value with no schema tag over it — a richtext field (where most stored content lives), a plaintext field, an array<richtext> element, a content property on an object field, a $seed overlay, and $ext, which is arbitrary host data by contract. A walk that rewrote something in $ext that merely looked like content would be corrupting a consumer's payload, so there is no walk that finds all the stored content and nothing else.

Had the decoder been made para-only, every table in one of those values would have failed to open: an unknown line kind is ParseError::UnknownName, which refuses the row rather than dropping the line.

So the read is in the decoder, it is one arm — "island" | "para" => LineKind::Para — and it is permanent. A legacy DTO retires when no row carries its tag; these values carry no tag, so "no rows remain in that shape" is not checkable. Read-repair converges the population — a card body rests in the current spelling once its row is written back, a field's content once the field is — and cold values keep the arm alive. The ignored loss key rests on the same reasoning.

A schema is loaded, not deserialized

QuillConfig, CardSchema and FieldSchema serialize and do not deserialize. The way to a config is QuillConfig::from_yaml_with_warnings, or a Quill read off a file tree.

A field arrives in two steps: a parse, which folds the sibling keys into the type payload (inline:, values:, members:, max:), and a shape walk, which rejects an object without properties or with an empty one, an array without items, and the placements — a nested ui.group, variants: on a non-enum or where no world may open, ui.layout: table on anything but a typed table of leaf columns. Deserialize ran the parse alone, so a config assembled that way carried states no Quill.yaml can reach, absorbed downstream without a diagnostic. from_quill_value, the parse half, is crate-internal for the same reason.

Serialization is untouched. schema(), schema_yaml() and the JSON Schema emission all read it, and the emitted form is a read of a loaded quill rather than an input form.

A matrix roster is flat

qualifications:
  type: matrix
  members:
    sq_cc_candidate: Sq/CC Candidate
    flight_cc: Flight CC
    dodin_ops: DODIN Ops

The group: heading over a block of members was honored by the visual editor and by nothing else. It never entered the value, the address or the vocabulary — members are one flat key space, a lookup searches the whole roster, and the blueprint emits matrix<sq_cc_candidate | flight_cc | dodin_ops>, so the tier that most needs the taxonomy never saw the partition. What it bought over separate fields was one shared properties: map, declared once instead of per block.

A page that prints its roster in sections declares a field per section, each with its own properties:. A plate loop loses one interpolation:

#for (id, m) in data.qualifications {
  [#(if m.held { "[x]" } else { "[ ]" }) #m.title #m.detail]
}

A matrix member mapping takes the ordinary ladder

Two rules decided what a stored member spelling meant: a bare scalar is the tick, and a mapping naming no held is held. The second retires.

qualifications:
  flight_cc: true                            # held, unchanged
  dodin_ops: { held: true, detail: "2024" }  # held, unchanged
  sq_cc_candidate: { detail: "2024" }        # was held; now unheld

A member is an object of a synthesized held: {type: boolean, default: false} beside the declared columns, so a mapping is the member object already and the tick it names no key for resolves as every other absent cell does — to its default:. The old rule overrode that ladder for one key in one type, which cost more than the spelling it saved: two mappings one key apart meant opposite ticks, and the shorter one ticked. Any producer building the member object without conforming — an editor writing a column before the tick, a serde-built payload — set a tick it never wrote. An empty mapping ticked a member holding nothing at all.

Nothing reports the flip. The value is well-typed under both readings, so the document loads, validates and renders; the box prints unticked and the member's columns print at their blanks, since an unheld member's columns never reach the page. A second signal goes quiet with it: a defaultless column inside such a member warned validation::must_fill while the member read as held, and an unticked member obliges nothing, so that warning leaves too.

No single grep finds the form, since a member key is a roster id and a mapping may be inline or a block. Inline spellings narrow by hand from:

grep -rn ':[[:space:]]*{' <your documents> | grep -v held

A block mapping under a member id needs the roster: for each matrix field in your quills, search its member ids and read the two lines under each hit. Spell held: true on every member meant to be ticked. A member meant to be unticked needs no edit, and neither does the bare scalar or a mapping that already names held. Nothing else about the type moves: an unheld member still retains its columns in the document and still renders them at their blanks.

A dormant variant world is its own cells, commented out

A blueprint showed one world of a variant-bearing enum and named the rest, one # when CUI: controlled_by, poc, category, limited_dissemination line each. The name list is gone. Every world now sits under a # when <MEMBER>: header, the selected one's cells live and every other one's commented out:

classification: # enum<UNCLASSIFIED | CUI | CONFIDENTIAL | SECRET | TOP SECRET>
  value: ""
  # when CUI:
  # # Office or organization that designated this information as CUI. Required by DoDM 5200.48.
  # controlled_by: !must_fill SAF/AA # string
  # # CUI category from the DoD CUI Registry. Leave blank to omit.
  # category: "" # string

The name line said which cells a world brings and nothing else: not where they go, not what they look like, not whether they are obliged. A commented cell says all of it, in the form every live cell on the page already takes, and the reader activates a world by setting the discriminant and deleting #.

Nothing else moves. The block is comments, so a blueprint parses to the same field values it did before — the container holds value and the live world's cells, and a dormant cell reaches neither the validator nor the render floor. A consumer that read the blueprint as text and matched # when <MEMBER>: still matches the header; one that parsed the name list after it reads the cells instead. A blueprint grows by roughly one line per dormant cell plus its description.

validate compiles the plate

quillmark validate renders the three canonical documents — the empty document, the blueprint, the seed — through the quill's backend, so a quill whose plate does not compile exits 1 where it exited 0. The check is the quill authoring contract, which until now was enforced over the fixtures quiver alone: a plate that reads a blank badly still rendered the seed, which is what render with no input file gives it.

A pipeline that wants the configuration read on its own — no backend, no compile — passes --no-render. The schema and file-reference diagnostics, their codes, and the summary line are unchanged. A render failure adds cli::canonical_document_failed plus the backend's own diagnostics; a quill naming a backend the engine does not register, which nothing read before, is cli::backend_unresolved and exits 1; and a render's own warnings join the -v list.

Checklist

  • Drop loss from every island literal and from anything that read it.
  • Replace every seam read of line.kind === 'island' with a read of the island backing the line's slot. npm run typecheck finds these. Where a field's content comes from getStored, read it through reader.getContent instead, or read island as para.
  • Drop the setKind from every block-island bundle. The type checker finds these too; they would have landed as para regardless.
  • Fix Rust matches over LineKind and StoredDocument and destructures of Island, and any predicate that read takes_continuations alone to decide whether a line takes continuations.
  • Recompute anything keyed on a content hash or on stored document bytes, for documents holding an island or a table.
  • Load the quill where a serde_*::from_* of QuillConfig, CardSchema or FieldSchema stood, and read the diagnostics it returns.
  • Flatten every matrix members: roster, and drop m.group from the plates that printed it.
  • Flatten any ui.layout: table column that is not a leaf, or drop the key from that field.
  • Read a dormant variant world's cells where the # when <MEMBER>: <fields> name list was read, and re-baseline anything asserting on blueprint bytes.
  • Run quillmark validate over every quill you ship, and fix the plate it refuses; pass --no-render where a gate is meant to read the configuration alone.
  • Spell held: true on every matrix member stored as a mapping that names no held and is meant to be ticked. This is the one edit your documents need, and the only break here that nothing reports.
  • Leave your stored documents alone otherwise.