0.104 → 0.105 — the blank is a property of the field, and obligation leaves default:¶
Two axes separate in this release, and a field now answers them independently. Value is what the cell holds; obligation is whether a human must author it.
On the value axis, an enum no longer borrows a real variant to mean "nobody answered." Every field has a blank — its spelling of "explicitly nothing" — and an enum's is "", reserved and outside values:. That closes a silent bug: an absent enum with no default: used to render values.first(), a choice nobody made, indistinguishable at the plate from a deliberate one and reachable from a cosmetic values: reorder. The same change settles a three-way disagreement about a date's "", widens the accepted domain for every enum, and renames zero_value / FieldSource::Zero to blank / FieldSource::Blank so the floor function, the enum, and the vocabulary are one word.
On the obligation axis, must_fill: takes the signal default: was carrying on the side, and Quill::validate gains a schema-side trigger that reaches documents the old payload walk could not see.
| Break | Surface | Action |
|---|---|---|
An absent no-default: enum renders "", not the first variant |
rendered output | Declare a default: where a concrete variant was intended |
Every enum accepts "" as input, so it reaches the plate |
plates | Branch over values ∪ blank; an else fallback is no longer total |
date: "" under a non-blank default: renders blank, not the default |
rendered output | Remove the "" where the default was intended |
"" declared in values: is a load error |
Quill.yaml | Delete the member; keep default: "" |
zero_value → blank |
Rust | Rename the import |
FieldSource::Zero → Blank; wire token "zero" → "blank" |
Rust, WASM | Match on blank |
| An incomplete document no longer validates clean | consumers of validate |
Route on the diagnostic's trigger arg, or treat the warning as advisory |
Two additive surfaces: must_fill: on a field (with quillmark:must_fill on the transform schema), and ui.blank_title naming an enum blank's label (with quillmark:blank_title).
The blank¶
Every field already had a render floor — the value that fills an absent cell so compile_data is total and a plate never guards. For most types that floor was honestly blank ("", [], the empty content). For enum it could not be: the floor was written to return a value from the type's domain, and an enum's domain was exactly values:, so it returned values.first().
Nothing forces an enum's blank to sit inside values:. Now it doesn't:
| Type | Blank |
|---|---|
string, date, datetime |
"" |
enum |
"" — reserved, never a member |
richtext, plaintext |
the empty content |
array |
[] |
object |
every property at its own blank, recursively |
integer, number |
0 |
boolean |
false |
values: enumerates choices; every other surface ranges over values ∪ blank.
An unanswered enum renders blank¶
A document omitting classification used to render UNCLASSIFIED. It now renders "". This applies to enums nested inside typed dictionaries too, whose recursive blank switches with it.
If the first variant was the intended fallback, say so — that is what default: is for:
A values: reorder is now a render no-op for every document, so reordering is safe in a MINOR. Removing or renaming a member is still breaking.
The domain widens for every enum, not only defaultless ones¶
This is the larger surface, and it is easy to miss because the previous section reads like it covers everything. It does not.
format: "" used to be a fatal EnumViolation at the validation gate. It now coerces, validates, and reaches the plate. That is a class of value your plate has never seen, on every enum you ship — including ones with a default:, which the first break leaves untouched.
A plate that branches non-totally silently absorbs it:
// Before — an authored blank renders the dow seal, silently.
letterhead_seal: image(
if data.at("letterhead_seal", default: "dow") == "dod" { "dod.png" } else { "dow.png" }
),
data.at(key, default: X) is not a guard here: under blank-filled render every declared key is always present, so its default: is dead code and the blank flows straight through. Branch over the blank explicitly and decide what it means — omit the parameter, take the package's own default, or render nothing:
// After — the blank omits the seal, which is what the package's own
// `letterhead_seal: none` default already means.
..if data.at("letterhead_seal", default: "") != "" {
(letterhead_seal: image(
if data.letterhead_seal == "dod" { "dod.png" } else { "dow.png" }
))
},
Where a downstream package asserts membership, an unguarded blank is worse than a quiet mis-render: it fails the compile. Audit every enum your plate reads, not only the ones without a default:.
date: "" stops meaning "absent"¶
Three sites disagreed about an empty date: coercion mapped it to null, validation accepted it, the floor produced it. Coercion was the outlier and the only user-visible one — it made the same authored literal mean "explicitly nothing" for string and "absent" for date.
The blank now outranks a default: for date and datetime exactly as it always has for string and array. Delete the "" from any document that meant to take the default.
"" in values: is a load error¶
A quill declaring the blank as a member fails to load with quill::enum_blank_member:
# 0.104 — the author hand-rolling the blank
classification:
values: ["", UNCLASSIFIED, CUI]
default: ""
# 0.105 — the engine supplies it
classification:
values: [UNCLASSIFIED, CUI]
default: ""
default: "" stays valid and keeps meaning exactly what it meant before. This looks like an inconsistency and is not one: values: enumerates choices, default: is a value, and the blank is a legal value that is never a choice.
Where the empty state is itself a decision the document should record, it is a member — undecided, waived, n_a — not the blank. The blank means nobody chose; a member means someone chose "none".
The obligation axis¶
default: was carrying two independent facts on one bit: what fills the cell, and whether a human still owes it an answer. Only the diagonal of the 2x2 was reachable — "a safe value renders, but a human must still confirm it" had no spelling, and neither did "optional, with nothing to suggest." must_fill: splits the second fact off:
# A safe value renders, and a human must still confirm it.
classification:
type: enum
values: [UNCLASSIFIED, CUI]
default: UNCLASSIFIED
must_fill: true
# Genuinely optional, with nothing to suggest.
internal_note:
type: string
must_fill: false
Left unset it derives default.is_none(), so every existing quill keeps the marker set its blueprint already emitted. Nothing in Quill.yaml needs editing to keep working; write must_fill: only for a combination the derivation cannot reach.
Quill::validate reaches documents it used to miss¶
validation::must_fill now fires from two triggers under the one code, named by the diagnostic's trigger arg:
trigger |
Fires when |
|---|---|
marker |
The document carries a !must_fill tag, with or without a value |
unauthored |
The schema obliges the cell and the document leaves it absent or present-null |
Only marker existed before, and it was a walk over the document payload that never consulted the schema. A hand-written document, a programmatically built one, or anything predating the field drew no completeness signal at all, whatever Quill.yaml declared. That is the hole unauthored closes.
This is the break. A merely incomplete document used to validate clean; it now returns one warning per unauthored obliged cell. Absence is still never malformed, and obligation still never gates render — but a consumer whose rule is "any diagnostic means not done" changes its verdict on documents that did not change:
// Unchanged document, new verdict.
const diags = quill.validate(doc)
if (diags.length) { /* now reached where it was not before */ }
Route on the trigger where the two mean different things to you, or filter the code out entirely where the warning is advisory:
const blocking = quill.validate(doc).filter(d => d.code !== 'validation::must_fill')
const outstanding = quill.validate(doc).filter(d => d.code === 'validation::must_fill')
// d.args.trigger is 'marker' | 'unauthored'
Where both would fire on one cell, one diagnostic is emitted and marker wins: its hint is the actionable one.
What discharges an obligation¶
Authoring a value, authoring the field's blank, or dropping the marker by hand. Clearing the key does not:
subject: "Quarterly review" # discharged: a value
subject: "" # discharged: deliberately nothing is an answer
subject: null # NOT discharged: nobody answered
# subject omitted # NOT discharged
This is the one place null ≡ absent does not carry. On the value ladder the identification is unqualified and permanent — both blank-fill, identically. But obligation asks whether a human made a call, and writing the blank is one while clearing the key is not. The practical consequence is for editors: removeField and writing the blank stop being the same act, and a UI that renders both as an empty box shows nothing of the difference.
Which paths are cells¶
The obligation keys on cell presence, not on the resolved source rung, so a must-fill leaf inside a container someone has already touched still warns. A typed dictionary is never itself a cell: it recurses to its leaves, present or absent, so the first keystroke into a container does not collapse N warnings into one. Every other type is its own cell, arrays included — [] is a real answer.
The cell set is exactly the set of paths the blueprint stamps, so a fresh seed and an empty document report the same cells. Seeding stamps the marker on example-seeded obliged cells to make that true: a seeded example is present and in-domain, and without the marker it would read as done.
Renames¶
zero_value becomes blank:
// 0.104
use quillmark_core::zero_value;
let floor = zero_value(&field);
// 0.105
use quillmark_core::blank;
let floor = blank(&field);
FieldSource::Zero becomes FieldSource::Blank, and its wire token "zero" becomes "blank":
// 0.104
type FieldSource = "authored" | "default" | "zero";
if (row.source === 'zero') { … }
// 0.105
type FieldSource = "authored" | "default" | "blank";
if (row.source === 'blank') { … }
The WASM TypeScript union changes with it, so a type checker reports the stale comparison. Python's resolved-value view is deferred and has nothing to migrate.
The projections¶
The transform schema emits the wire-valid domain, which leads with the blank, plus quillmark:blank_title when the author names one:
Without this a standard JSON-Schema validator would reject a value the engine accepts.
QuillConfig::schema() keeps emitting values: verbatim: it is the declaration view, and injecting the blank there would emit a schema that no longer loads. It gains must_fill only where the author wrote one — the raw declaration, never the derived answer, so a quill round-tripping through the view does not come back with every field's obligation pinned. The WASM QuillFieldSchema and QuillFieldUi interfaces declare must_fill?: boolean and blank_title?: string to match.
pdfform Choice widgets bound to an enum lead their options with the blank, so a blank cell has an option to land on — coerce_choice keeps a value only when it matches a declared option. A widget declaring its own options in form.json has no schema field behind it and is unchanged.
A consumer's picker must keep the blank selectable and re-selectable. Returning to it is how an author clears a cell back to unset, so HTML's disabled-placeholder idiom is the wrong one here; Django's ("", "---------") and Rails' include_blank: are the right shape.
Not affected¶
Documents in storage are untouched — no re-save, no re-seed. The content model, the document syntax, and the plate-JSON wire shape are unchanged. integer, number and boolean blanks (0, false) stay indistinguishable from authored zeros, as does any container over them; that seam is permanent and now documented rather than implied.