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 |  | 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:
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:
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:
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
lossfrom 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 typecheckfinds these. Where a field's content comes fromgetStored, read it throughreader.getContentinstead, or readislandaspara. - Drop the
setKindfrom every block-island bundle. The type checker finds these too; they would have landed aspararegardless. - Fix Rust
matches overLineKindandStoredDocumentand destructures ofIsland, and any predicate that readtakes_continuationsalone 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_*ofQuillConfig,CardSchemaorFieldSchemastood, and read the diagnostics it returns. - Flatten every matrix
members:roster, and dropm.groupfrom the plates that printed it. - Flatten any
ui.layout: tablecolumn 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 validateover every quill you ship, and fix the plate it refuses; pass--no-renderwhere a gate is meant to read the configuration alone. - Spell
held: trueon every matrix member stored as a mapping that names noheldand 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.