0.105 → 0.106 — a variant-bearing enum rests as a container, and a schema address steps one property in¶
One change runs through this release: a container property becomes addressable. A schema address may step one level into a declared container, so classification.poc names a cell rather than the container holding it, and enum gains variants: — a per-member field set that makes such a container worth declaring in the first place.
The break no type checker reports comes first: region addresses shift. Two entries below change a string your code already reads without changing a type your compiler already sees, and this is the one a consumer is most likely to be matching on.
| Break | Surface | Action |
|---|---|---|
| A plate read stepping into a declared container regions on the property | consumers matching FieldRegion.field / ContentHit.field / RenderedRegion.field |
Match the deeper address; doc.pathFor covers the prefix grammar around it |
Declaring variants: rests the field as a container, not a bare string |
documents, plates, editors | Author {value: <member>, …}; read data.<field>.value |
usaf_memo's four cui_* fields move under classification's CUI variant |
documents against usaf_memo |
Move the four keys into classification and drop the prefix |
EditError::UnknownField becomes a struct variant |
Rust | UnknownField { field, at } |
| An undeclared property anchors its diagnostic at the property | Rust, WASM, Python | Expect main.address.zip where main.address came before |
Nothing in Quill.yaml needs editing to keep working: variants: is opt-in, and a field that does not declare it is untouched at every projection. Documents in storage are unaffected except against a quill whose schema you change.
Region addresses shift¶
This is the second release running that the address grammar under FieldRegion.field moved — 0.104 respelled the array index (main.references[0]), 0.106 adds the property step — and both times the field stayed typed string. A consumer matching on it gets no compile error and no runtime error, just a match that stops firing.
A plate reading a declared container property now regions on the cell:
// A plate reading `#data.classification.poc`
// 0.105
region.field === 'main.classification'
// 0.106
region.field === 'main.classification.poc'
The step is gated twice, which bounds the blast radius:
- The property must be one the container declares. A key outside the schema is no address, and the read falls back to the container exactly as before.
- The read must chain directly off
data. A plate that binds the container first (#let c = data.classification…#c.poc) surfaces no region at all — not the container's, not the cell's — because the tracked site is the binding, where no ink is drawn. That is the standing "values laundered through intermediate bindings" non-guarantee in PREVIEW.md, unchanged here;field-region("classification.poc")[#c.poc]claims the ink for such a read.
So a container read whole (#data.classification) is unchanged, and so is a read of an undeclared key.
Stop hand-spelling the part of the address that has actually moved. doc.pathFor mints the canonical DocPath string, and region addresses use that same grammar, so the root and card prefix come from a call rather than from a literal:
// Brittle: every segment is a literal, including the ones that moved.
regions.filter(r => r.field === 'main.classification.poc')
regions.filter(r => r.field === `cards.indorsement[${i}].date`)
// Minted: the root, the card kind and its ordinal move with the grammar.
const addr = doc.pathFor({ field: 'classification.poc' })
regions.filter(r => r.field === addr)
pathFor writes a field name verbatim, so a dotted "classification.poc" passes through as text and is not itself protected against a future respelling of the property step. What it covers is the prefix grammar — main, cards.<kind>[n] — which is where the churn has been.
Where you must match a family rather than one address, match a prefix on segment boundaries (r.field === addr || r.field.startsWith(addr + '.')) so a container's regions and its cells' regions stay distinguishable. A bare startsWith was already wrong before this release and is wronger now.
The variant container¶
An enum may declare variants:, a field set that exists only in the world where the discriminant holds that member:
classification:
type: enum
values: [UNCLASSIFIED, CUI, CONFIDENTIAL, SECRET, TOP SECRET]
default: ""
variants:
CUI:
controlled_by: { type: string }
poc: { type: string }
category: { type: string, default: "" }
variants: is the one key that changes a field's resting shape. The field rests as {value: <member>, …that member's fields} at every projection, where a variantless enum rests as a bare string. This is why the entry is breaking even though the key is opt-in: declaring it on an existing field changes what every downstream surface sees for that field.
value is reserved and no variant may declare it (quill::variant_reserved_field_name).
What a document authors¶
# 0.105 — a bare enum; cells conditional on it were separate card fields,
# scoped by a name prefix and by prose.
classification: CUI
# 0.106 — the discriminant and its world's cells are one container.
classification:
value: CUI
controlled_by: SAF/AA
poc: Capt J. Smith, DSN 555-1234
The bare scalar stays valid: classification: CUI is the spelling of a world carrying no variant answers, and coercion normalizes both, so one shape reaches every surface downstream. A document that never authored the variant cells needs no edit.
An authored cell whose variant is out of play is kept and warned (validation::out_of_variant), never dropped at coercion or gated at render — so the ordinary editor gesture (choose CUI, fill the block, flip away to compare, flip back) does not spend the author's answers.
What a plate reads¶
The discriminant moves from the field to its value cell:
// 0.105
#if data.classification == "CUI" { … }
// 0.106
#if data.classification.value == "CUI" { … }
Inside a branch over values ∪ blank — which a plate has owed every enum since 0.105 — every declared field of that world is present and needs no guard. The wire carries exactly the live world: the render floor emits value plus the selected member's fields, blank-filled as usual, and nothing else. Outside the branch there is nothing to guard, because nothing is there. The blank owns no field set, so an unanswered discriminant renders {value: ""}.
The ceiling¶
Enforced at load rather than discovered at render. A variant carries plain data only — string, enum, number, integer, boolean — sits at card level only, and cannot declare value. A name two variants declare differently is quill::variant_field_collision: the name is one cell of the container whichever world brings it into play, so two readings of it would coerce a live value under the other world's type. Identical declarations, which is what repeating a shared field set or sharing a YAML anchor produces, collapse to that one cell and stay legal.
usaf_memo: the cui_* fields move¶
The fixture quill's four cui_* card fields move under classification's CUI variant and shorten:
| 0.105 | 0.106 |
|---|---|
cui_controlled_by |
classification.controlled_by |
cui_poc |
classification.poc |
cui_category |
classification.category |
cui_limited_dissemination |
classification.limited_dissemination |
controlled_by and poc drop their default: "" and are therefore obliged — on a CUI memo only, which is what DoDM 5200.48 requires and what the flat spelling could state only in description: prose.
The render fails quietly; validate is the detection route. Nothing rejects the stale keys — an undeclared card key draws no diagnostic, documents being free to carry opaque data — and nothing reads them either: the plate reads data.classification.controlled_by, the moved key reaches nothing, and a CUI memo renders with an empty indicator block.
What does report it is quill.validate(doc). controlled_by and poc are obliged in the CUI world, and the obligation walk descends into the container, reading a bare classification: CUI as the discriminant — so a stale document selecting CUI draws validation::must_fill (trigger unauthored) at main.classification.controlled_by and main.classification.poc. Sweep with validate, not with a render diff:
# Before
classification: CUI
cui_controlled_by: SAF/AA
cui_poc: Capt J. Smith, DSN 555-1234
cui_category: PRVCY
cui_limited_dissemination: FEDONLY
# After
classification:
value: CUI
controlled_by: SAF/AA
poc: Capt J. Smith, DSN 555-1234
category: PRVCY
limited_dissemination: FEDONLY
usaf_memo keeps its declared version: 0.2.0 across this change, so the $quill: usaf_memo@0.2.0 selector on a stored document does not report it. Audit documents by content, not by reference.
EditError::UnknownField is a struct variant¶
It now carries the in-field path its two siblings (FieldDecode, FieldNotContent) already carried:
// 0.105
Err(EditError::UnknownField(name)) => …
// 0.106
Err(EditError::UnknownField { field, at }) => …
field stays the bare field name; at is the steps from the field to the undeclared name, empty when the field itself is the undeclared one. The edit::unknown_field code, its field arg, and the whole-field message are unchanged.
What changes on every binding is the anchor for an undeclared property. get_content_at("address", [Key("zip")]) against an address declaring no zip reported field 'zip' is not declared in the schema and anchored at the field; it now reports field 'address.zip' and anchors at main.address.zip. The old form read as a claim about a top-level field and collided outright when a real top-level field shared the name. A consumer routing on Diagnostic.path for this case gets the deeper path.
Additive¶
field-region(field, body)claims the inkbodydraws for a schema field, so a plate can tie content it composes — a banner, a package-built block, a computed table — tosession.regions()andsession.fieldAt(). Layout-neutral and a fallback claim, never an override. A claim whose closing marker never reaches the frame yields nothing rather than everything, and warnstypst::unclosed_field_region.form-fieldtakesfont,size, andalign, so an injected widget's value can be set to match the type around it. All three are rejected on"checkbox"and"signature". A field setting none of them stamps byte-identically to before.FieldSchemagainsvariantsandvariant_field;VariantFieldsandVARIANT_DISCRIMINANT_KEYare new on the Rust surface, andQuillFieldSchema.variantson the WASM one.- The pdfform binder descends a variant container to match, so one address binds on either backend where
address.citybound only on pdfform and asserted on Typst.
Not affected¶
Stored blobs, the document syntax, the content model, and the plate-JSON wire shape for any field that does not declare variants:. A quill declaring no variants projects identically at every surface. form.json still carries no widget styling, so the pdfform flatten path and the canvas preview are unchanged.