Given a plain-aggregate action type A (registered with
BRIDGE_REGISTER_ACTION),
morph::forms produces a standard JSON Schema a client can render a form from,
and provides a compile-time validate() body that gates submission until every
required empty-capable field is filled in. It builds on glaze's
write_json_schema<A> (which already contributes types, $defs, per-field
metadata from glz::json_schema<A>, and ExtUnits — glaze's per-unit metadata
block, detailed under Renderer contract
below — from morph::units::Quantity) and closes the gaps glaze leaves open.
Every feature below obeys one rule, which is what reconciles "rapid GUI development" with "flexible when the generated form isn't enough":
- Infer from the type where possible. A
Quantityfield already knows its unit and precision; aChoicealready knows its options action; astd::optionalalready means "not required." The renderer gets as far as it can from types alone, with zero extra user declaration. - Declare to override. When inference is ambiguous or insufficient (a
label, a layout group, a widget choice, a cross-field rule), the user adds a
typed, compile-time declaration — a
static constexprmember or a small registration macro on the action (these macros are hand-aligned behind// clang-format off; the rationale lives once inCONTRIBUTING.mdunder Formatting/linting, and each site carries a pointer rather than a copy). Never mandatory; absence falls back to a sensible convention. - Escape hatch always available. The schema below is a documented, stable contract (see "Renderer contract"). Anything the generated GUI cannot express, an app builds by consuming the schema directly or overriding one field's widget (see "Theming / component-override registry").
This is why the constraints placed on a model's action types are light: flat,
default-constructible, reflectable aggregates whose fields come from the known
palette (Quantity, Choice, Timestamp, primitives, or a user type exposing
hasValue()), plus optional typed declarations. Convention buys rapid;
override + direct-schema-consumption buys flexible.
Every schema key this module and its siblings (choice.md,
views.md, workflows_navigation.md)
introduce is additive and optional — the emitted schema stays unversioned,
and a renderer that doesn't recognize a new x-* key, or a new top-level
view/wizard/app document, ignores it harmlessly. Renaming, retyping, or
changing the meaning of an existing key is the only kind of change reserved for
a major release.
The Qt/QML client (src/qt/forms) is the reference renderer these specs
write concrete examples against, because it already consumes the schema
contract; the schema contract itself stays renderer-agnostic — every
x-* key and view/wizard/app-schema document is specified in platform-neutral
terms so a web, ImGui, or other renderer can implement the same contract.
- Design principle: infer by default, declare to override
- Empty state —
EmptyCapableFieldconcept Choice— server-sourced picklistFixedString— NTTP compile-time string- Widget hints —
Multiline/Ranged schemaJson<A>()— schema generation- Field metadata —
FieldMeta - Layout & grouping — sections, tabs, spans
- Renderer contract: the schema key vocabulary
- Shipped Qt/QML reference renderer
- Renderer conformance kit
- Theming / component-override registry
- Localisation — message keys and the catalog seam
allRequiredEngaged<A>()— readiness check- Cross-field rules — the
x-rulesvocabulary - Computed fields
- Per-instance constraints — values that live in data
- Support traits and helpers
- API reference
- Design decisions
- Failure modes
- Limitations
- Cross-references
- Out of scope
A field type that has an internal blank state (nothing entered / nothing
selected) exposes hasValue() -> bool. This is the only thing the forms
module needs to know about a field to decide whether it counts as "engaged" —
a field is engaged exactly when hasValue() returns true. Every later
use of "engaged" in this document (required-ness, cross-field rules, computed
fields) means precisely this.
template <typename T>
concept EmptyCapableField = requires(const T& field) {
{ field.hasValue() } noexcept -> std::convertible_to<bool>;
};The noexcept requirement is load-bearing: allRequiredEngaged is itself
noexcept, so a hasValue() that can throw must not cross that boundary. A type
whose hasValue() is not noexcept silently fails the concept and is treated
as a non-empty-capable field (always engaged), so it never gates submission —
the noexcept clause is what surfaces that mistake at compile time.
Satisfied by:
morph::units::Quantity<U, Dec>—hasValue()returnstruewhen theRationalpayload is present.morph::forms::Choice<T, ...>—hasValue()returnstruewhen itsstd::optional<T>is engaged.morph::time::Timestamp—hasValue()returnstruewhen itsDateTimepayload is present.morph::util::Tagged<T, Tag>—hasValue()always returnstrue: it wraps a required protocol scalar, not an optionally-empty one, so it opts into this concept the same way the others do but never gates submission (seetagged.md).- Any user type that exposes
bool hasValue() const noexcept.
A non-empty-capable field (plain int64_t, std::string, …) is always
considered engaged — forms cannot know whether it has been "filled in" without
application-specific logic, so allRequiredEngaged simply skips it.
A field whose value is chosen from options served by another registered action. Options are not hardcoded on the client — they come from executing the named action over the same wire, and the result rows are mapped to a combo box.
template <typename T, FixedString OptionsAction,
FixedString ValueField = "id", FixedString LabelField = "name",
FixedString... DependsOn>
struct Choice {
std::optional<T> value;
// ...
};T— the value type submitted on the wire (the JSON payload exchanged between client and server over the transport — see wire.md for the envelope this travels inside;int64_tfor ids,stringfor codes).OptionsAction— the registered action type id whose result provides options (executed with an empty body whenDependsOnis empty, or with{name: value, ...}built from theDependsOnnames otherwise; returns{valueField, labelField, ...}rows either way).ValueField/LabelField— which result-row fields carry the submitted value and the display label; both default to"id"/"name".DependsOn— an optional trailing pack of sibling wire field names whose current values parameterise the options action (a cascading picklist); empty by default. See choice.md for the full design.
On the wire a Choice is just its nullable T — the options metadata lives in
the C++ type and the generated schema only, never in payloads. The
glz::meta<Choice<...>> specialisation reflects value directly, so glaze
serialises it as T | null.
A structural type that lets string literals be used as non-type template parameters (C++20 NTTP):
template <std::size_t N>
struct FixedString {
std::array<char, N> data{};
consteval FixedString(const char (&literal)[N]) noexcept;
constexpr std::string_view view() const noexcept;
};Used by Choice to embed the options-action name, value field, and label field
in the type itself.
Two more thin wrappers carry rendering control intent in the type, in the
same spirit as Choice: a Multiline field is a std::string that should be
edited as a text area, and a Ranged<Min, Max, Step> field is a bounded
numeric that should be edited as a slider.
struct Multiline {
std::string value;
static constexpr std::string_view widget() noexcept { return "textarea"; }
};
template <auto Min, auto Max, auto Step = 1>
struct Ranged {
std::optional<decltype(Min)> value;
bool hasValue() const noexcept { return value.has_value(); }
static constexpr auto min() noexcept { return Min; }
static constexpr auto max() noexcept { return Max; }
static constexpr auto step() noexcept { return Step; }
static constexpr std::string_view widget() noexcept { return "slider"; }
};Both serialise through glz::meta as their bare payload — Multiline as a
plain JSON string, Ranged as a nullable number — so the wire is unchanged.
Neither type is std::optional itself, so both are required by the
Required-ness rule unless opted out via
optionalFields. Ranged additionally satisfies EmptyCapableField
(hasValue() is noexcept), so it gates allRequiredEngaged exactly like
Choice; Multiline does not (a plain std::string payload has no
distinguishable "empty" state the forms module tracks) and so is always
considered engaged, same as an unwrapped std::string member.
mergeSchemaExtras emits x-widget on any property whose field type declares
a noexcept static constexpr widget() — the shape both types above expose —
and x-min / x-max / x-step on any property whose field type additionally
declares min() / max() / step() (the Ranged shape). An action may also
override the widget for any field — wrapped or plain — by naming it in the
same static constexpr fieldMetadata array the field-metadata
feature uses, as long as its entries expose
.field and a non-empty .widget (both string-view-convertible); this is
read structurally (duck-typed), so this header does not gain a named
dependency on FieldMeta's declaration — any type shaped that way is
honoured, and the override always wins over a type's own derived widget().
Full API, the $defs-collapse caveat shared with Choice, and design
rationale are in widget_hints.md.
Produces a complete JSON Schema string for action type A, post-processing the
output of glz::write_json_schema<A>() to add seven annotation groups:
| Annotation | Scope | Contents |
|---|---|---|
required |
Top-level, and every nested-aggregate object schema (see Nested aggregates (recursive, cycle-safe)) | Array of field names that are not std::optional<...> and not listed in A::optionalFields. Always written, overwriting whatever glaze produced: glaze never derives required from member types — it emits one only where a type declares meta<V>::required (and for a tagged variant's discriminator) — so morph does not rely on its absence. |
x-order |
Every property | The member's declaration index (0‑based), so a renderer lays fields out in declaration order regardless of JSON key ordering. |
x-decimalPlaces |
Quantity properties |
The field's declared precision (Quantity<U, Dec>::declaredDecimals). |
x-unitAlternatives |
Quantity properties |
Convertible display/entry units derived from UnitTraits::relations, each with {id, display, decimals, num, den} — id/display/decimals come from the alternative unit's UnitMeta, and num/den are the exact alternative-to-canonical ratio. Omitted entirely when the field's unit declares no convertible units. |
x-optionsAction / x-optionValue / x-optionLabel |
Choice properties |
The action that serves the options and which result fields to use. |
x-optionsDependsOn |
Choice properties whose options depend on sibling fields |
Wire names of the sibling fields that parameterise the options action; omitted when the Choice declares no dependency. |
x-widget / x-min / x-max / x-step |
Properties whose field type declares widget() (optionally min()/max()/step()), or any field named in a fieldMetadata-shaped override |
The preferred control id, and (for a bounded numeric) the slider's track bounds and increment (widget_hints.md). |
The result is computed once per type and cached in a static const std::string
inside schemaJson<A>(). On internal failure (malformed intermediate JSON,
etc.) the unmerged glaze schema is returned — or an empty string when even
glaze's own write_json_schema<A>() failed, since schemaJson feeds
mergeSchemaExtras with write_json_schema<A>().value_or(std::string{}).
Schema generation throws in exactly one case, and never for malformed
input: an A::formRules declaration that contradicts A's own derived
required array so completely that no submission could satisfy both — see
Unsatisfiable declarations.
Required is the default — the safer choice for domain forms, since forgetting
to mark a field optional loses data rather than silently accepting a gap (see
Design decisions for the full rationale). A member is
optional (and therefore not added to required) when any of:
- Its type is
std::optional<...>, or - Its name appears in
A::optionalFields— astatic constexpriterable ofstd::string_viewthat the action declares, or - It is the destination of an
A::computedFieldsentry — a derived, read-only field is never something the user must fill in; see Computed fields.
Required-ness is derived here, and x-rules is derived from A::formRules,
independently. They can therefore disagree; the one disagreement that makes
the form unsubmittable is rejected at generation, see
Unsatisfiable declarations.
struct RecordMeasurement {
std::int64_t sampleId = 0;
Density density{};
Moisture moisture{}; // optional
static constexpr std::array optionalFields{std::string_view{"moisture"}};
[[nodiscard]] bool validate() const { return morph::forms::allRequiredEngaged(*this); }
};The actual workhorse behind schemaJson. It parses the glaze schema into a
glz::generic_u64 DOM (preserving int64/uint64 bounds in $defs),
iterates reflected members via forEachNamedMember, and patches the DOM in
place. If the input schema is not valid JSON the raw string passes through
unchanged.
Patching the DOM in place means the walkers do two different things through the same syntax, and only one of them is safe to leave unchecked:
- A write —
property["x-order"] = I,dom["required"] = names— means to create the member.operator[]'s insert-on-missing is the behaviour wanted, and these sites keep it. - A read — "is there an
itemsnode?", "does$defshold this key?" — must not create anything. A read that inserts adds a null member to the schema being emitted, and (becauseglz::generic_u64's object storage reallocates on insert) can invalidate a node reference an enclosing frame of the mutually recursive walk still holds.
detail::findMember(node, key) is the read. It returns a pointer to the
member, or nullptr when node is not an object or holds no such key; the
caller branches on that instead of subscripting. It replaces a
contains(key) + operator[](key) pair, which probed the same map twice, at
every read site in forms.hpp, instance_constraints.hpp and views.hpp
whose key is not guaranteed present — see "Not every read" below. The pointer is
into node's own storage, so a caller may write through it — and, exactly like
the reference operator[] returns, it is invalidated by any insertion into
node.
Node is deduced, so a const DOM yields a const member and both
constnesses share one implementation with no const_cast and no copy.
Why this is morph's own rather than glaze's.
cppcoreguidelines-pro-bounds-avoid-unchecked-container-access fires on every
one of these subscripts and advises a "bounds-safe alternative". On
glz::generic_json there is none. at(key) is defined as
{ return operator[](key); } for both overloads (glaze v7.4.0,
glaze/json/generic.hpp:320 and :322), and the non-const operator[] it
forwards to inserts a default-constructed member for a missing key
(generic.hpp:201-211):
| spelling | non-const DOM | const DOM |
|---|---|---|
node[key] |
inserts a null member and returns it | glaze_error("Key not found.") — throws |
node.at(key) |
identical: it is operator[] |
identical: it is the const operator[] |
findMember(node, key) |
nullptr, DOM unchanged |
nullptr, DOM unchanged |
So the checking depends on the constness of the DOM, not on the spelling, and
these walkers are mutating by construction. A mechanical operator[] → at()
sweep over them would silence ~70 findings while changing a read into a write
on exactly the inputs the check warns about.
tests/test_forms_dom_access.cpp asserts both halves — that findMember leaves
the document byte-identical on a miss, and that at() on the pinned glaze does
not — so a glaze release that gives at() real checked semantics turns that
file red rather than leaving this rationale quietly stale.
The sites that carry a standing
NOLINT(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) are the
writes, and the directive says so.
Not every read is converted. views.hpp reads a const DOM, where
operator[] throws rather than inserts — a different and louder failure than
the mutating walkers', but still one the caller cannot see coming. Its reads are
checked with one deliberate exception, stated here because the exception is the
interesting part: a read whose key is guaranteed present by construction keeps
its subscript. Turning such a read into a null check adds
a branch nothing can take — untestable code, and a branch-coverage allowlist
entry someone must later write a justification for. That is a cost, not a
safety improvement.
Two reads of rowDom["properties"] are that exception. glaze's schema writer
emits "properties" unconditionally for every reflectable aggregate, including
a zero-member one (measured with a standalone glz::write_json_schema<T>()
probe; llvm-cov reports 0 hits on deriveColumns's !contains("properties")
early return across all ten instantiations the suite exercises), and
deriveColumns has already tested the key before buildColumnEntry can read
it. So the split in views.hpp is six reads converted and two left, against
21 writes untouched — measured with
clang-tidy -p build/clang-debug --extra-arg=-std=c++23 \
--extra-arg=-Wno-missing-include-dirs --quiet \
--checks='-*,cppcoreguidelines-pro-bounds-avoid-unchecked-container-access' \
include/morph/forms/views.hpp
which reports 29 findings in the file before (21 writes + 8 reads) and 20
after, with no new category. 23 of those 29 remain in the source; three of them
now carry a
NOLINT(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access), because
converting a read moved the write beside it onto a changed line and
clang-tidy-diff reports on changed lines. Those three are the first
suppressions of this check in views.hpp, and each says in one word what it
is: a write. The other 18 writes are unsuppressed, because only a changed line
is reported; converting the tree in one sweep is a separate job from this one.
Counted a second way as well, through tests/test_views.cpp, a translation unit
that actually instantiates the templates: the two measurements agree exactly,
site for site, before and after. That is worth recording because it does
not generalise — forms.hpp under-reports when analysed as a main
file, because a subscript inside an uninstantiated template body is dependent
and the check cannot see it. views.hpp's subscripts are all on the
non-dependent glz::generic_u64, so there is nothing for instantiation to
reveal.
An action declares per-field presentation — label, help, placeholder,
read-only, hidden — with a static constexpr std::array<FieldMeta, N> (or,
for the describe<>() sugar, a static const array defined out-of-line —
see below) named fieldMetadata, mirroring the optionalFields convention
above: a compile-time declaration on the action type, surfaced through the
schema.
struct FieldMeta {
std::string_view field; // wire key of the member
std::string_view label{}; // "" = infer from name
std::string_view help{}; // "" = omit description
std::string_view placeholder{}; // "" = omit x-placeholder
std::string_view widget{}; // widget-selection override (see widget_hints.md)
bool readOnly{false};
bool hidden{false};
std::string_view i18nKey{}; // "" = derive the key stem; see below
std::optional<math::Rational> minimum{}; // disengaged = no floor
std::optional<math::Rational> maximum{}; // disengaged = no ceiling
std::optional<math::Rational> multipleOf{}; // disengaged = any value
std::string_view unit{}; // "" = no display unit (plain members only)
std::optional<math::DecimalPlaces> decimals{}; // disengaged = no display precision
BlankAs blankAs{BlankAs::Omit}; // Empty = a cleared string submits "" (strings only)
};
struct RecordMeasurement {
Choice<std::int64_t, "ListSamples"> sampleId;
Density density{};
Moisture moisture{};
static constexpr std::array fieldMetadata{
FieldMeta{.field = "sampleId", .label = "Sample",
.help = "Which logged sample this measurement belongs to."},
FieldMeta{.field = "density", .placeholder = "e.g. 1050"},
FieldMeta{.field = "moisture", .readOnly = true},
};
};Absence of fieldMetadata leaves every field at its inferred default: a
title derived from the member name, nothing else. mergeSchemaExtras
looks up (via detail::findFieldMeta<A>) the entry, if any, whose field
matches each reflected member and patches the property node — the same
property node that already carries x-order and the Choice/Quantity
keys (see "Where the keys physically land" below). An entry naming a field
that does not exist on the action is silently ignored: no crash, no stray
property.
When no descriptor overrides a field's label, detail::inferTitle derives a
title from the wire key: split on camelCase and underscore boundaries,
capitalise each word — dryMassPct → "Dry Mass Pct", sample_id →
"Sample Id", a single-word notes → "Notes". This is a pure function of
the member name, so it costs nothing per action and needs no declaration. A
descriptor's non-empty label always wins over the inferred title, and
title is always emitted — an unannotated action gains only this key,
otherwise unchanged.
describe<MemberPtr>(label, help) builds a FieldMeta whose field is
resolved from the pointer-to-member itself (detail::memberWireName), so the
wire key is never restated as a string:
static const std::array<morph::forms::FieldMeta, 2> fieldMetadata;
// ... after the class's closing brace:
inline const std::array<morph::forms::FieldMeta, 2> RecordMeasurement::fieldMetadata{
morph::forms::describe<&RecordMeasurement::sampleId>("Sample", "Which logged sample…"),
morph::forms::describe<&RecordMeasurement::moisture>().withReadOnly(),
};FieldMeta::withPlaceholder(text), ::withReadOnly(), and ::withHidden()
each return a modified copy, so describe<>()'s result can be extended
fluently as shown above. describe<>() produces the exact same property
annotations as the equivalent hand-written FieldMeta{.field = "…", ...}
literal.
describe<>() is deliberately not constexpr/consteval, and a
fieldMetadata array built from it must be declared inside the class and
defined just after its closing brace rather than as a single in-class
initializer, for two reasons verified while implementing this feature:
- Incomplete-type self-reference. A static data member's in-class
initializer is evaluated while the enclosing class is still incomplete
(unlike a member function body or a default member initializer, neither
of which this is); resolving
&RecordMeasurement::sampleId's wire name requires constructing a probeRecordMeasurementinstance, which an incomplete type cannot do. - glaze's reflection is not
constexprfor reflectable aggregates.glz::get_member, whichdetail::forEachNamedMembercalls, is an ordinary runtime function — so even resolving the name outside the class cannot happen inside aconstexpr/constevalfunction.
The plain FieldMeta{.field = "sampleId", ...} literal form is unaffected by
either restriction (it never references the enclosing class) and stays a
single in-class static constexpr array.
The three numeric members are the one part of FieldMeta that is not
presentation. They declare a bound on one field's value, and one declaration
drives both halves of it: schemaJson<A>() serves them as the standard
JSON-Schema keys of the same names, and allFieldBoundsSatisfied<A>(action)
evaluates them in C++ so an action's validate() enforces the identical
numbers the client was shown.
struct CreatePaste {
Reads burnAfterReads; // Quantity<Unit::count, 1>; empty = no burn limit
static constexpr std::array<morph::forms::FieldMeta, 1> fieldMetadata{
morph::forms::FieldMeta{.field = "burnAfterReads",
.minimum = math::Rational{1, math::DecimalPlaces{1}},
.multipleOf = math::Rational{1, math::DecimalPlaces{1}}},
};
[[nodiscard]] bool validate() const noexcept {
return morph::forms::allFieldBoundsSatisfied(*this);
}
};FieldMeta::withMinimum(r) / ::withMaximum(r) / ::withMultipleOf(r) return
modified copies, so a describe<>()-built entry can carry a bound too.
Why this is not part of the x-rules vocabulary. Every comparison node
there — greater, greaterOrEqual, less, lessOrEqual — takes two member
pointers of the same action; only equals accepts a literal, and it
expresses equality alone. So "at least 1" had no right-hand operand to name,
and integrality had no spelling at all. x-rules is also, by its own title,
the cross-field vocabulary: a bound on a single field's value is a property
of that field, and belongs on its property node beside title and
x-decimalPlaces rather than in a rule node whose fields array would hold
one entry.
Why not UnitTraits::bounds. That is a decode-side check keyed by
unit, not by field (checkQuantityBounds),
so a floor declared for CreatePaste::burnAfterReads would equally constrain
PasteView::readCount — the same Quantity type over the same unit, which
legitimately starts at 0. It also has no integrality vocabulary, and never
touches schemaJson<A>().
Where the keys land, and why it matters. On the property node, beside
the $ref — never in the $def the $ref points at. Two members of the same
Quantity type share one $def, so a bound written there would leak onto both
and reintroduce exactly the per-unit behaviour above. The shipped renderer's
resolveRef merges the property node over the resolved definition, so it
reads a per-field bound with no special case (see
Where the keys physically land).
For a Quantity member the bound is on the scalar value the field denotes,
in the canonical unit — not on the {num,den,dp} object the member
serialises as. That is the reading both the shipped renderer (which compares
the entered value only while the canonical unit is selected) and
allFieldBoundsSatisfied (which compares the engaged math::Rational) apply.
What the C++ predicate checks. allFieldBoundsSatisfied reads bounds from
three member kinds and no others: a Quantity (its engaged Rational), a bare
math::Rational, and an integral member every value of which is exactly
representable as a Rational numerator — every signed integer type, plus every
unsigned one narrower than 64 bits. A declaration on any other member — a
std::string, a bool, a std::uint64_t — is inert in C++; the schema still
advertises it. An unengaged EmptyCapableField is vacuously satisfied,
exactly as the x-rules comparison kinds are: a form still being filled in
must not fail a bound on a field that has no value yet, and whether the field
must be filled at all is required/allRequiredEngaged's question.
Exactness. The C++ comparisons run on the exact Rational, never on a
double; multipleOf uses math::checkedDiv, so a quotient too large to
represent is refused rather than silently saturating into an integer. The
served number is a JSON number and therefore an approximation for a
non-integral bound — an integral bound is emitted as an integer and picks up
the usual x-exactMinimum/x-exactMaximum
companion above 2^53. As everywhere else in this contract, the live client gate
is an approximation and the model is the floor.
multipleOf must be strictly positive, as JSON Schema requires: a zero
divisor has no meaning and a negative one divides the same set of values as its
magnitude. A non-positive declaration is ignored — neither emitted nor checked.
A per-instance x-minimum/x-maximum written by
InstanceConstraints
composes with a compiled bound rather than replacing it: the renderer checks
both, so an instance range narrows the declared one and never widens it.
A Quantity carries its unit and its precision in its type. A DTO whose
numeric members are plain doubles has neither, so FieldMeta declares them:
struct RecordDensity {
double density = 0.0;
double temperature = 0.0;
static constexpr std::array fieldMetadata{
FieldMeta{.field = "density", .unit = "kg/m³", .decimals = math::DecimalPlaces{3}},
FieldMeta{.field = "temperature"}.withUnit("°C"),
};
};unitis emitted asExtUnits, the key aQuantity's unit already travels in, with the one string in bothunitAsciiandunitUnicode. Every reader of a unit therefore finds a plain member's where it finds aQuantity's: the shipped renderer's unit suffix,SlotRegistry.byUnit, and a view'sv-columnsentry (views.hppcopiesExtUnitsoff the property node). It is presentation only: nothing converts through it, and it never reaches the payload.decimalsis emitted asx-displayDecimals, deliberately notx-decimalPlaces.x-decimalPlaceshands a property the exact{num,den,dp}encoding (see Plain number fields: a declared precision wins over the"number"type), and adoublemember cannot decode that object.x-displayDecimalskeeps the JSON-number encoding and only tells the renderer how many fraction digits to show and accept. It is read only for a"number"property with nox-decimalPlaces.- Both are ignored on a
Quantitymember, whose unit and declared precision are part of its type and already emitted; a second declaration could only disagree with the first. AdecimalsabovekMaxDecimalPlacesis ignored too, as a non-positivemultipleOfis. - Both apply at any depth, like the rest of
FieldMeta: astd::vector<Row>element's ownfieldMetadatastamps the row type's properties.
Neither is checked server-side. Like placeholder, they are presentation;
the one gate that follows from decimals is the renderer's entry limit below.
A renderer omits a blank control from the payload, and for a create form that
is right. An edit form prefilled from a stored record is different when the
model reads an absent std::optional<std::string> as "leave unchanged" and
"" as "clear". There, a user who deletes a prefilled remark sends nothing,
so the stored text survives. FieldMeta::blankAs = BlankAs::Empty
(or .withBlankAs(BlankAs::Empty)) emits "x-blankAs": "empty", which makes the
blank control submit "" — but only once the field is engaged:
static constexpr std::array fieldMetadata{
FieldMeta{.field = "remark", .blankAs = BlankAs::Empty},
};Field state (since the last prefill / resetFields) |
Blank control submits |
|---|---|
Prefilled with a string, "" included |
"remark": "" |
Non-blank at any point (typed into, setFieldValue, a slot's setValue), then cleared |
"remark": "" |
Never prefilled with a string and never non-blank; a stored null counts as not prefilled |
nothing (omitted, as before) |
That rule has two consequences. A stored "" round-trips: prefill → submit sends "".
A create form in which the user types and then clears a field sends "" as well.
- String members only. The C++ side emits the key only on a
std::string/std::optional<std::string>member.DynamicFormreads it only for a field of kindstring, so a number, a closed set, aChoiceor aTimestampignores it. None of those has a""spelling. requiredis unchanged. A required field left blank is still unfilled, and the form is not ready.- Presentation only. Nothing changes on the wire or in the model. The key only decides what a renderer assembles.
tests/test_forms_blank_as.cpp pins the emission.
src/qt/forms/tests/tst_DynamicFormBlankAs.qml (12 cases) pins the renderer
against submitted bodies. Making a cleared field never submit "" reddens 4 of
those cases. Dropping the engagement rule, so an untouched field also submits
"", reddens 6.
x-readonly and x-hidden are presentation only. The field still travels in
the payload — a hand-built wire envelope can set it freely regardless of
either flag. Enforcement of anything security-sensitive stays server-side
(see security.md); a truly secret field must not be a
member of the action at all.
| Key | Where | JSON type | Meaning / renderer obligation |
|---|---|---|---|
title |
property node (sibling of $ref) |
string | The field's display label — an explicit FieldMeta::label, else the inferred title-cased member name. Always emitted. |
description |
property node (sibling of $ref) |
string | Help text, from FieldMeta::help. Omitted when empty. A non-empty help overrides any description glaze stamped from a glz::json_schema<A> block; an empty help leaves an existing glaze-authored description untouched. |
x-placeholder |
property node (sibling of $ref) |
string | In-control placeholder/hint shown while the field is empty, from FieldMeta::placeholder. Omitted when empty. Never submitted. |
x-readonly |
property node (sibling of $ref) |
boolean | true when the field should be displayed but not editable. Emitted only when true. |
x-hidden |
property node (sibling of $ref) |
boolean | true when the field should not be shown at all; the field remains part of the action payload. Emitted only when true. |
x-i18nKey |
property node (sibling of $ref) |
string | An explicit message-key stem override, from FieldMeta::i18nKey. Omitted when empty. Not a complete key by itself — see Localisation — message keys and the catalog seam for how a renderer expands it per text slot. |
x-widget |
property node (sibling of $ref) |
string | Control-selection override, from FieldMeta::widget. Omitted when empty. Full mechanism, precedence over a type's own derived widget(), and design rationale are in Widget hints. |
minimum |
property node (sibling of $ref) |
number | Inclusive lower bound on the field's value, from FieldMeta::minimum. Omitted when not declared. Standard JSON-Schema vocabulary, not an x-* key — see Per-field scalar bounds. |
maximum |
property node (sibling of $ref) |
number | Inclusive upper bound, from FieldMeta::maximum. Omitted when not declared. |
multipleOf |
property node (sibling of $ref) |
number | The field's value must be an exact integer multiple of this, from FieldMeta::multipleOf. 1 is how "whole number" is spelled. Omitted when not declared, or when the declared value is not strictly positive. |
ExtUnits |
property node (sibling of $ref) |
object | A plain member's display unit, from FieldMeta::unit, as {"unitAscii": unit, "unitUnicode": unit} — the shape a Quantity carries. Omitted when empty, and never emitted for a Quantity member. See Display unit and decimals. |
x-displayDecimals |
property node (sibling of $ref) |
non-negative integer | A plain number's display and entry precision, from FieldMeta::decimals. Omitted when disengaged, above kMaxDecimalPlaces, or on a Quantity member. |
x-blankAs |
property node (sibling of $ref) |
string | "empty", from FieldMeta::blankAs = BlankAs::Empty: once engaged, a blank string field submits "" instead of being omitted. Emitted only for Empty on a std::string / std::optional<std::string> member. See Clearing a string in an edit form. |
All thirteen keys are additive and non-breaking, extending the renderer-contract table below without renaming or retyping any existing key, per this program's versioning stance (see "Design principle" above). A renderer that ignores them falls back to today's behavior exactly: it shows the raw wire key as the caption, no helper/placeholder text, every field editable and visible, and no scalar bound gated client-side (the model still refuses an out-of-bounds value).
An action may declare visual structure over its flat field list: a
static constexpr formLayout groups fields into titled sections, tabs, or
an accordion panel, and a parallel static constexpr fieldSpans widens
individual fields in a grid renderer. Both mirror the optionalFields
convention above — a static constexpr list mergeSchemaExtras looks for by
name, present only when an action opts in. Absent either, schemaJson<A>()'s
output is unchanged: no x-layout, x-group, x-section, or x-colspan key
is emitted, and a renderer lays every field out exactly as it always has
(flat, x-order order).
groupKindName(GroupKind) (forms/layout.hpp) is the one place the three
enumerators are spelled for the wire — "section", "tab", "accordion", in
x-layout.groups[].kind. It is also the downgrade path: an out-of-range value
names itself "section", matching the renderer's documented fallback for a
group kind it does not implement.
// morph::forms::FieldGroup / FieldSpan / GroupKind — forms/layout.hpp.
enum class GroupKind { Section, Tab, Accordion };
struct FieldGroup {
std::string_view title; // section / tab / panel heading
GroupKind kind{GroupKind::Section};
std::span<const std::string_view> fields; // member wire keys (membership;
// intra-group order is x-order)
};
struct FieldSpan {
std::string_view field; // wire key
int colspan{1}; // grid columns this field spans
};
struct RecordMeasurement {
Choice<std::int64_t, "ListSamples"> sampleId;
Density density{};
Moisture moisture{};
std::string notes;
static constexpr std::array kIdent{std::string_view{"sampleId"}};
static constexpr std::array kMeas{std::string_view{"density"},
std::string_view{"moisture"}};
static constexpr std::array kNote{std::string_view{"notes"}};
static constexpr std::array formLayout{
FieldGroup{.title = "Identity", .fields = kIdent},
FieldGroup{.title = "Measurement", .fields = kMeas},
FieldGroup{.title = "Notes", .kind = GroupKind::Accordion, .fields = kNote},
};
static constexpr std::array fieldSpans{
FieldSpan{.field = "notes", .colspan = 2},
};
};Each group carries its own kind: Section (the default) renders as a
titled fieldset stacked vertically, consecutive Tab groups render as panes
of one shared tab bar, and Accordion renders as a collapsible panel.
x-order remains the sole authority on intra-group ordering —
formLayout's array order gives the cross-group order and a group's
fields list gives membership only. A field named in no group falls into an
implicit trailing default group, in x-order order, so declaring a group for
some fields never hides the rest — the no-formLayout case is simply one
implicit group containing every field, which is exactly today's flat form.
mergeSchemaExtras<A> (see above) stamps this onto the DOM in a pass that
runs only when A::formLayout / A::fieldSpans exist
(detail::HasFormLayout<A> / detail::HasFieldSpans<A>, forms/layout.hpp):
the ordered group list becomes a single top-level x-layout object, and each
reflected member (via forEachNamedMember) gets x-group/x-section (if it
is named in a group) and x-colspan (if its declared span exceeds 1). A
group naming a field the action does not have is silently ignored (schema
generation never throws); a field claimed by two groups keeps the first
one that names it.
The reference QML renderer (examples/forms/gui_qml/qml/DynamicForm.qml)
buckets its flat fields array into sections keyed on each field's
x-section, merges consecutive "tab"-kind sections into one shared tab
bar (renderRuns), and lays each section's fields out in a 2-column grid
honoring x-colspan — falling back to a single implicit flat section
(one column, no chrome) when the schema carries no x-layout at all.
A host chooses the grid: gridColumns. DynamicForm.gridColumns (default
2) is the column count of every field grid the form builds: each section's,
each tab set's, and the one it creates inside a host's section or tab-set
chrome. flatGridColumns is the implicit flat bucket's — the
whole form without x-layout, and the trailing group of fields no group names.
It is 1 at the default, the pre-grouping renderer's single column, and
follows gridColumns as soon as a host sets that to anything else, so one
property puts the whole form on the host's grid:
DynamicForm { gridColumns: 12 } // x-colspan 6 = half a row, 4 = a third, 12 = fullA field spans x-colspan columns (1 when absent), clamped to its grid's
column count, so a span wider than the grid takes one full row rather than
widening the grid. Both properties are bindings; changing one relays the form.
With both left at their defaults the layout is unchanged.
src/qt/forms/tests/tst_DynamicFormGridColumns.qml pins it (9 cases); fixing
every grid back at 2/1 columns reddens 5 of them, and dropping the clamp another
5.
Tab switching destroys and rebuilds controls, so they re-seed from
fieldValues. The tab bar drives its Repeater off
sections[currentTab].fields, so leaving a tab destroys that tab's field
delegates and returning to it creates new ones. A control's text otherwise
flows only outward (via onTextChanged into fieldValues) and never back,
so a returned-to tab showed empty controls while the form went on
auto-submitting the values it still held — sending data the user could not see.
Each text-bearing control therefore re-seeds itself from fieldValues on
Component.onCompleted, under the programmaticEdit suppression so
re-creating a control never fires the action. A text: binding would not work
here: prefill assigns text imperatively, which would break it.
This is the normative list of every key a renderer must understand to build
a form from a morph action schema. Standard JSON-Schema keywords (type,
properties, $defs, $ref, numeric bounds, …) are emitted by
glaze and behave per the JSON-Schema 2020-12 spec; the table below covers the
keys morph either synthesises (required, title, description when a
FieldMeta::help is declared, minimum/maximum/multipleOf when a
FieldMeta declares them, the x-* extensions) or relies on glaze to
stamp (format, ExtUnits, description when no FieldMeta::help overrides
it, minimum/maximum for a scalar member's own type range). A renderer that
ignores an x-* key
still produces a usable form — it just loses the affordance that key carries
(unit selector, field order, combo box, decimal step).
This table is the complete reference for every key; two rows (x-rules,
x-computed/inputs) name concepts — cross-field rules and computed fields —
that get their own full explanation later in this document
(Cross-field rules,
Computed fields). Skip ahead to those sections first if
the two rows below aren't self-explanatory on a first read.
A Quantity (or any aggregate) member is not inlined into its property.
glaze emits the member's type once into top-level $defs and the property node
carries only a $ref pointing at it, e.g.:
"$defs": {
"quantity_kg_per_m3": { "type": "object", "ExtUnits": { "unitAscii": "kg_per_m3", "unitUnicode": "kg/m³" }, ... }
},
"properties": {
"density": { "$ref": "#/$defs/quantity_kg_per_m3", "x-order": 2, "x-decimalPlaces": 1, "x-unitAlternatives": [ ... ] }
}The two kinds of annotation therefore live in different nodes, and a renderer
must resolve the $ref to see both:
ExtUnitslives in the$defof the unit type — glaze stamps it onto theQuantity's type definition, not onto the property. Many properties of the same unit type share one$defand therefore oneExtUnits.x-order,x-decimalPlaces,x-unitAlternatives,x-optionsAction/x-optionValue/x-optionLabel/x-optionsDependsOnare siblings of the$refon this property —mergeSchemaExtraspatchesdom["properties"][name], which is the property node holding the$ref. This is still true for aQuantity/Choiceproperty's own$def(thequantity_kg_per_m3-style def shown above never getsx-order/required/ title — onlyExtUnitsand glaze's owntype/bounds/descriptionlive there). It is not true for a nested-aggregate member's$def: see Nested aggregates (recursive, cycle-safe) below — that$defdoes getrequired/x-order/title/etc. patched directly into it, the same as any other object schema.
The "Where" column below names the node each key is written to. A renderer
resolves the $ref into $defs, then merges: per-property x-* keys (from the
property node) win, and ExtUnits (plus glaze's type/bounds/description) come
from the resolved def. The shipped MorphForms QML renderer's (src/qt/forms,
below) DynamicForm.qml's resolveProp does exactly this dual read.
| Key | Where | JSON type | Meaning / renderer obligation |
|---|---|---|---|
required |
top-level (object), and every nested-aggregate object schema (inlined property or $defs entry) — see Nested aggregates (recursive, cycle-safe) |
array of strings | Names of members that must be engaged before submit. A member is listed unless it is a std::optional<...>, appears in A::optionalFields, or is a computedFields destination (see the Required-ness rule). Always emitted (an explicit [] when nothing is required). The renderer blocks submission until every listed field has a value. |
x-order |
property node (sibling of $ref) |
non-negative integer | The member's 0-based declaration index. Renderers lay fields out in ascending x-order, not in JSON key order (object key order is not preserved across DOMs). |
x-decimalPlaces |
property node (sibling of $ref) |
non-negative integer | The field's declared precision (Quantity<U, Dec>::declaredDecimals, unit default unless the type overrides it). The numeric input step / rounding granularity for entry in the canonical unit. Enforced, not merely advisory: the request/reply dispatch path rounds each submitted Quantity to this precision before storing it — the stored value, not just its tag, is reduced (see Advertised precision is enforced on dispatch). A model serving one instance of an action may overwrite this with a value from data — see Per-instance constraints; x-instanceConstraints (below) says when it did. |
x-unitAlternatives |
property node (sibling of $ref) |
array of objects | Convertible display/entry units for the field, derived from UnitTraits<E>::relations. Omitted entirely when the unit declares no convertible peers. Each element has the five subfields below. The renderer offers these as a unit selector and recomputes the entered value exactly on switch; the submitted payload is always in the canonical unit (the one named by ExtUnits). |
↳ id |
alternative entry | string | Stable ascii id of the alternative unit (UnitMeta::id). |
↳ display |
alternative entry | string | Human display text of the alternative unit (UnitMeta::display). |
↳ decimals |
alternative entry | non-negative integer | The alternative unit's own default decimals (UnitMeta::defaultDecimals) — the input step to use while that unit is selected. |
↳ num |
alternative entry | signed integer | Numerator of the exact alternative→canonical ratio. |
↳ den |
alternative entry | signed integer | Denominator of that ratio. value_in_canonical = value_in_alternative · num / den; num/den are the Rational numerator/denominator of the composed relation, so the recompute is exact (no floating-point drift). |
x-optionsAction |
property node (sibling of $ref) |
string | Type id of the registered action whose result rows populate this field's combo box. Executed with an empty body, unless the property also carries x-optionsDependsOn (below), in which case the request body is {parentField: value, ...} built from the named sibling fields' current values. |
x-optionValue |
property node (sibling of $ref) |
string | Which result-row field carries the value submitted on the wire (default "id"). |
x-optionLabel |
property node (sibling of $ref) |
string | Which result-row field carries the display label (default "name"). |
x-optionsDependsOn |
property node (sibling of $ref) |
array of strings | Wire field names of sibling fields whose current values parameterise this field's options action (a cascading picklist). The renderer sends {name: value, …} as the options-action request body instead of an empty one, and re-fetches — clearing a now-invalid selection — whenever any listed field changes. Omitted entirely when the Choice declares no dependency. |
title |
property node (sibling of $ref) |
string | The field's display label — an explicit FieldMeta::label, else a title-cased member name (dryMassPct → "Dry Mass Pct"). Standard JSON-Schema vocabulary, not an x-* key. Always emitted. See "Field metadata" above. |
x-placeholder |
property node (sibling of $ref) |
string | In-control placeholder/hint shown while the field is empty, from FieldMeta::placeholder. Omitted when empty; never submitted. |
x-readonly |
property node (sibling of $ref) |
boolean | true when the field should be displayed but not editable. Emitted only when true — including on every computedFields destination (see Computed fields). Not a security control — see "Field metadata is not a security control" above. |
x-computed |
property node (sibling of $ref) |
object | Marks the field as derived. Present when the action declares it as a computed(...) destination (Computed fields); absent otherwise. |
↳ inputs |
x-computed object |
array of strings | Wire field names of the sibling fields the value derives from, in declaration order. Advisory to the renderer; authoritative computation is the server's — see Where the value is authoritative. |
x-hidden |
property node (sibling of $ref) |
boolean | true when the field should not be shown at all; the field remains part of the action payload. Emitted only when true. Not a security control. |
x-widget |
property node (sibling of $ref) |
string | The preferred control id: "textarea", "slider", "radio", "combo", "password", "checkbox", … A fieldMetadata-shaped override (a .field/.widget entry, read structurally — see widget_hints.md) wins; else the field type's own widget() (Multiline, Ranged). Advisory — a renderer that lacks the named control falls back to the type-default control (text area → text field, slider → numeric input, radio → combo). Omitted when neither a wrapper type nor an override supplies one. |
x-min |
property node (sibling of $ref) |
number | Slider lower bound, from Ranged::min(). Emitted only for a Ranged field. Distinct from glaze's schema minimum (a validation bound, when present) — x-min is the control track start and is never enforced. |
x-max |
property node (sibling of $ref) |
number | Slider upper bound, from Ranged::max(). Emitted only for a Ranged field. |
minimum |
$def (glaze's own bound for the member's scalar type), or the property node when the field declares FieldMeta::minimum |
number | Inclusive lower bound the renderer must refuse values below. The property node wins over the $def on merge, which is what makes a declared bound per-field rather than per-type — see Per-field scalar bounds. For a Quantity property it bounds the scalar value in the canonical unit, not the {num,den,dp} object. |
maximum |
as minimum |
number | Inclusive upper bound, same sources and same reading. |
multipleOf |
property node (sibling of $ref) |
number, strictly positive | The value must be an exact integer multiple of this; 1 means "whole number". Emitted only from FieldMeta::multipleOf — glaze never stamps it. A renderer that ignores it loses the client-side gate only; the model still refuses. |
x-exactMinimum |
wherever minimum sits (property node, or the $def reached through its $ref) |
string | Exact decimal spelling of minimum, emitted only when the bound's magnitude exceeds 2^53 — i.e. when an IEEE-754 double cannot hold it. See Exact numeric bounds. |
x-exactMaximum |
wherever maximum sits |
string | Exact decimal spelling of maximum, under the same condition. |
x-step |
property node (sibling of $ref) |
number | Slider / numeric increment, from Ranged::step(). Emitted only for a Ranged field. For a Quantity the entry granularity remains x-decimalPlaces (above); x-step is not emitted for Quantity. |
x-minimum |
property node (sibling of $ref) |
object {num,den,dp} |
Inclusive lower bound for the field's value, from a model's InstanceConstraints — an exact Rational in the same wire shape as the value it bounds, never a double. Emitted only for a decorated instance schema (Per-instance constraints); never by schemaJson<A>(). Distinct from x-min (a slider track start, which is never checked). |
x-maximum |
property node (sibling of $ref) |
object {num,den,dp} |
Inclusive upper bound, same source and shape as x-minimum. |
x-instanceConstraints |
top-level (object) | array of strings | Wire field names whose keys were written from instance data rather than derived from the compiled action type. Present only on a decorated schema. A renderer needing to know whether an x-decimalPlaces/x-minimum/x-maximum is instance-sourced checks membership here rather than guessing. |
format |
Timestamp property (or its $def) |
string, value "date-time" |
Standard JSON-Schema vocabulary (stamped by glaze, not by morph). The renderer shows a date-time input; the wire value is the ISO-8601 string Timestamp serialises to. No x-* extension is used for timestamps. |
x-displayDecimals |
property node (sibling of $ref) |
non-negative integer | A plain "number" member's display and entry precision, from FieldMeta::decimals. Read only when the property has no x-decimalPlaces; the value keeps its JSON-number encoding, and an entry with more fraction digits is refused. See Display unit and decimals. |
ExtUnits |
$def of the Quantity's unit type (reached via the property's $ref) — or, for a plain member declaring FieldMeta::unit, the property node |
object | Glaze-stamped block describing the field's canonical unit. Two fields: unitAscii (the stable ascii id, e.g. "kg_per_m3" — sourced from UnitMeta::id) and unitUnicode (the human display text, e.g. "kg/m³" — from UnitMeta::display). This is the unit a payload value is always denominated in, and the reference point the num/den of every x-unitAlternatives entry converts to. A renderer resolves the property's $ref into $defs to read ExtUnits.unitAscii/unitUnicode (it is not on the property node next to the x-* keys) to label the field and anchor the unit selector. On a plain member it is the FieldMeta::unit display text in both subfields, and there is no unit selector. |
x-layout |
top-level (object) | object | The form's group structure: { "groups": [ { "title": string, "kind": "section"|"tab"|"accordion", "fields": [wire-key,…] }, … ] }, in A::formLayout declaration order. Emitted only when the action declares formLayout. The renderer builds the named containers in array order and places each field in its group; fields absent from every group go in a trailing default group. |
x-group |
property node (sibling of $ref) |
string | The title of the group this field belongs to. Omitted for a field in the implicit default group, or when x-layout is absent. |
x-section |
property node (sibling of $ref) |
non-negative integer | The 0-based index of this field's group in x-layout.groups. Omitted under the same conditions as x-group. |
x-colspan |
property node (sibling of $ref) |
positive integer | Number of grid columns the field should span, from FieldSpan::colspan. Emitted only when greater than 1 (the default, single-column width). A renderer laying fields out in a grid widens the control; a single-column renderer ignores it. DynamicForm clamps it to the grid's column count (gridColumns, see Layout & grouping). |
x-rules |
top-level (object) | array of rule objects | Cross-field rules the renderer must satisfy before enabling submit, and should surface live as inline errors. Emitted only when the action declares formRules; absent otherwise. A renderer that ignores it falls back to per-field required only. |
↳ kind |
rule / condition object | string | One of the closed vocabulary ids in the "Cross-field rules" section's table above (or a condition id: engaged, notEngaged, equals, and, or, not). An unrecognised kind — a rule or a nested condition — must be treated as "cannot evaluate": a third answer, distinct from both true and false. The renderer neither claims the rule is satisfied nor blocks submission on it; the payload reaches the server, which runs the compiled rule list and has no unrecognised-kind case. See Renderer fallback for the full contract and why it is defer, not block. |
↳ fields |
rule / condition object | array of strings | Wire field names the rule ranges over, in declaration order (operand order is significant for greater/less). Absent on and/or/not, which range over nested conditions (conditions/condition below) instead of fields directly. |
↳ when |
requiredWhen / visibleWhen / readonlyWhen object |
rule/condition object | The nested condition the rule keys on. Present only on these condition-bearing kinds. May itself be an and/or/not node (a compound condition), nested to any depth — see Compound conditions. |
↳ value |
equals condition object |
scalar / {num,den} |
The literal an equals condition compares against; a numeric literal is the exact Rational {num, den}, never a double. A bool literal is emitted as a JSON boolean, so a renderer must compare a boolean field as a boolean, not as its display text. |
↳ valueText |
equals condition object |
string | Exact decimal spelling of an integral value whose magnitude exceeds 2^53, emitted only in that case — the number itself does not survive JSON.parse, so comparing it would collapse literals the compiled evaluator keeps distinct. A renderer that evaluates equals must prefer valueText when present and compare digits. Same remedy as x-exactMinimum/x-exactMaximum for bounds. |
↳ conditions |
and / or condition object |
array of condition objects | The nested conditions combined by boolean AND / OR, in declaration order; each element is itself a full condition/rule object (any kind, including a nested and/or/not) — see Compound conditions. |
↳ condition |
not condition object |
condition object | The single nested condition negated by boolean NOT (singular key, since not wraps exactly one child). |
x-submitMode |
top-level (object) | string | "explicit" opts a side-effectful (non-query) action out of the shipped renderer's default auto-submit-on-validity behavior — see Explicit submit mode. Absent, or any value other than "explicit", keeps the default. Emitted by schemaJson<A>() when the action declares static constexpr bool explicitSubmit = true, the same way x-layout is emitted from formLayout. An action that declares nothing — or declares it false — emits no key at all. |
The shipped DynamicForm.qml renderer's default behavior is to call
controller.submitIfValid(actionType, bodyJson) the instant every field and
rule is satisfied — safe for a read-only query action, but unsafe for any
side-effectful (mutating) action: a mutation would fire on every keystroke
that happens to leave the form momentarily valid, with no user confirmation.
Setting the top-level "x-submitMode": "explicit" schema key opts a form out
of that default:
revalidate()still recomputesready/previewLinelive (sox-rules,required, and every other live-validation affordance are unaffected) but never callssubmitIfValidon its own.- The renderer instead shows an explicit Submit button (
objectName: "submitButton"), enabled only whileready— matching the existingx-order/required-asterisk convention of gating on the same readiness state the auto-submit label already reflected. Clicking it is the sole trigger;DynamicForm.submit()is the function it calls, itself a no-op unless the form is currently ready. - The button is loaded (via a
Loader,active: explicitSubmitMode) only when the schema opts in — a default (auto-submit) form has no such control anywhere in its item tree, not merely a hidden one.
Any schema describing a side-effectful action should carry this flag before being safely rendered by the shipped renderer; a schema that omits it (every existing schema, and any read-only query action) renders exactly as before — zero behavior change.
An action opts in with a static constexpr bool:
struct CreatePaste {
std::string title;
std::string body;
// Without this, the shipped renderer auto-submits on validity — which for
// a mutation means one stored paste per typed character.
static constexpr bool explicitSubmit = true;
};schemaJson<A>() then emits the top-level "x-submitMode": "explicit". This
is opt-in, exactly like formLayout, fieldSpans, and formRules: an
action that says nothing keeps the auto-submit default and its generated schema
is byte-for-byte unchanged, so adding the emitter changed no shipped schema.
Declaring explicitSubmit = false is the same statement as not declaring it.
The detection is the HasExplicitSubmit<A> concept — true when A declares
an explicitSubmit member convertible to bool, the same shape as
HasFormRules<A>. It answers only whether the member is declared; the
emitter reads its value afterwards, which is why = false and declaring
nothing produce the same schema.
Deriving the flag instead — from some "does this action mutate" predicate — is
deliberately not done. No such predicate exists in forms.hpp, and adding
one would flip the rendering of every existing generated form at once, which is
a shipped-behavior change rather than an emitter addition. Opting in per action
keeps the decision with the author who knows whether the action has effects.
glaze emits {"type": "array", "items": {...}} for a std::vector<T>
member, standard JSON-Schema vocabulary rather than an x-* extension. The
shipped DynamicForm.qml renderer gives it a dedicated
comma-separated-with-validation TextField control (objectName: "field_" + name, exactly like a scalar field's control — the two are mutually
exclusive per field, so exactly one claims that name) instead of falling
through to the plain-text control, whose fallback (JSON.stringify(text))
would wrap the typed text as a JSON string, not an array — a body the
server's schema validation always rejects.
Typed text is split on comma, each entry trimmed of surrounding whitespace,
and empty entries dropped: "red, green, blue" → ["red","green","blue"],
" red ,, green ," → ["red","green"]. A field with today's scope —
array-of-string — is fully supported; an items type other than "string"
still renders this control and still encodes each comma-separated entry as a
JSON string (not, e.g., a JSON number), so a std::vector<int> field is
usable but not yet type-checked per element the way a scalar Quantity/
integer field is. The submitted literal for a fully-blank array field
follows the same blank-means-unengaged convention as every other field
(fieldJsonLiteral returns null for empty/whitespace-only text), so an
optional, untouched array field is omitted from the request body entirely
rather than submitted as []. Once the field holds any non-whitespace
text, though — including a comma-only entry like " , , ", which is not
blank by that check even though every individual entry is dropped — it
encodes to a genuine empty array [], not null; a required array field
is satisfied by engagement (non-blank text), not by having at least one
surviving entry.
A std::vector<Row> member is {"type": "array", "items": {"$ref": "#/$defs/Row"}} (or items inlined, for a row type used once), and the row
type's $def is annotated like any object schema — x-order, title,
required, and every FieldMeta key its own fieldMetadata declares (see
Nested aggregates). The built-in
controls cannot collect it: the comma-separated array control encodes strings,
so the member is unrepresentable and a form that must
carry it is not ready.
A host slot that claims such a member makes it representable. The renderer describes the row type the way it describes the action, and hands the result to the slot:
| Field descriptor key | Meaning |
|---|---|
isObjectArray |
true for an array whose items resolve to an object schema with properties. |
itemFields |
The row type's member descriptors, in x-order order — the same shape as a top-level entry of fields (name, label, unit, decimals, readOnly, hidden, required, isQuantity/isNumber/isInteger/isBoolean/isEnum/enumOptions, …): a grid's columns. Filled for a top-level collection only; one level is described, never more, so a self-referential row type cannot loop. |
claimedBySlot |
true when a registered slot resolves for this member. unrepresentable is then "". |
The slot edits the rows as cell texts — setRows([{sieve: "31.5", passing: "100.0"}, …]), or setValue with the same array as JSON text — where
each cell holds what the built-in control for that member would hold: the typed
digits of a number or Quantity (in the display locale), "true"/"false"
for a boolean, the option's valueJson for a closed set. The form then encodes
every cell with the encoder the same member would get at the top level
(encodeFieldText): the same syntax, locale normalisation, precision limit and
declared bounds, so a Quantity cell becomes an exact {num,den,dp} and an
over-precise one is refused, not rounded. A blank optional cell is omitted from
its row object. The literal is null — no value, so the form is not ready —
when the text is not an array of objects, when a cell does not encode, or when
a required member of any row is blank. An empty array is a value: [] is
submitted for it.
Cells are always read in the member's canonical unit: a row has no unit
selector of its own. Only a top-level collection is handed to a slot. One
inside a nested object a slot claims
is encoded by that object's encoder, with the same row rules; one nested inside
a row stays unrepresentable. A label
inside a row resolves through an explicit x-i18nKey or its literal only — the
derived <action>.<field> key names top-level members, and no row-level stem
is defined on the C++ side.
src/qt/forms/tests/tst_DynamicFormObjectArraySlot.qml pins the contract
against submitted bodies. Replacing the cell encoder with plain string quoting
reddens 3 of its 19 cases.
A member whose type is a struct — IgnitionSpecimen specimen, or
std::optional<PycnometerDetermination> determination1 — is an object schema
with properties: inlined into the property when the type is used once,
otherwise a $ref into $defs, and for a std::optional wrapped in
{"anyOf": [<that>, {"type": "null"}]}. Its members are annotated like the
action's own, fieldMetadata included, at every depth and through the
std::optional (see Nested aggregates).
The built-in controls cannot collect it, so the member is
unrepresentable — unless a host
slot claims it, exactly as for a
collection of objects:
| Field descriptor key | Meaning |
|---|---|
isObject |
true for a member whose schema resolves to an object with properties that no typed control claims — not a Quantity or a Choice, whose own controls encode their object shape. kind is "object". |
objectFields |
The object's member descriptors, in x-order order, the same shape as a top-level entry of fields (label, unit, decimals, readOnly, required, the kind flags, …). Recursive: a member that is itself an object carries its own objectFields, and a collection member its itemFields. Described for a top-level member and inside another nested object, down to maxObjectDepth (4) levels below the action, and never twice for the same $defs type on one path, so a self-referential type stops at its first repetition — that inner member is left unrepresentable. |
claimedBySlot |
true when a registered slot resolves for this top-level member. unrepresentable is then "", for it and for every object or collection described inside it. |
The slot edits the value as cell texts: setObject({massOfContainer: "512.3", testTemperature: "538"}), or setValue with the same object as JSON
text. A leaf cell holds what the member's built-in control would hold, as in a
row; a nested object member holds a nested object of the same shape; a
collection member holds an array of row objects. The form encodes every leaf
with the encoder the same member would get at the top level
(encodeFieldText: locale normalisation, precision limit, declared bounds), a
nested object recursively, and a collection with the row encoder. So for
MaxDensitySection { PycnometerTestData data; }:
setObject({ useSpecificGravity: "false", testLiquidTemperature: "25.0", testLiquidName: "",
determination1: { massPycnometerEmpty: "1450.10", massPycnometerAndSample: "3450.25",
excluded: "false" },
determination2: {} })
// → {"data":{"useSpecificGravity":false,"testLiquidTemperature":25.0,
// "determination1":{"massPycnometerEmpty":1450.10,"massPycnometerAndSample":3450.25,
// "excluded":false}}}- A blank optional leaf is omitted. A blank optional object — absent,
{}, or one whose every member is blank (an empty collection counts as blank here) — is omitted too. - A blank required leaf, or a blank required object, makes the literal
null: no value, so the form is not ready. A required object therefore needs at least one member filled to count as present, even when all of its own members are optional. A value that is not an object, or a cell that does not encode, does the same. - A top-level object left blank (
setObject({})) is simply unfilled: omitted when optional, the ordinary submit gate when required.
Cells are read in each member's canonical unit. Prefill
decodes a stored object back into this shape (decodeFieldValue, member by
member, the inverse of the encoder), so prefill → no edit → submit carries the
same values; a stored optional object whose members are all absent decodes to
nothing and is then omitted.
src/qt/forms/tests/tst_DynamicFormObjectSlot.qml pins the contract against
submitted bodies, with the two consumer shapes above (BinderIgnitionSection,
MaxDensitySection). Replacing the leaf encoder with plain string quoting
reddens 9 of its 24 cases.
glaze emits {"type": "boolean"} for a bool member, and
{"type": ["boolean", "null"]} for a std::optional<bool>. The shipped
DynamicForm.qml renderer gives both a CheckBox (objectName: "field_" + name, mutually exclusive per field with the scalar and array controls, so
exactly one claims that name) rather than letting them fall through to the
plain-text control. The fallback there (JSON.stringify(text)) wrapped the
value as a JSON string — {"flag":"true"} — and, because a TextField
applies no validation of its own, accepted literally any text, so
{"flag":"banana"} was submitted just as readily. glaze rejects both with
expected_true_or_false; it does not coerce.
The control emits a bare true or false, never quoted. A bool member is
required (it has no null branch), and a checkbox always displays a definite
state, so a required boolean with no retained value is seeded false at
delegate creation rather than left blank — otherwise the form would show an
unchecked box while the required-field gate silently withheld submission, with
nothing on screen indicating what was missing. An optional boolean is left
unseeded and is omitted from the request body until the user touches it, which
is what distinguishes "not answered" from an explicit false for a
std::optional<bool> member.
glaze emits {"type": "number"} for a bare double or float member, under
$defs/double / $defs/float with the type's own range on the definition node:
"$defs": {"float": {"type": "number",
"minimum": -3.4028234663852886e+38,
"maximum": 3.4028234663852886e+38}},
"properties": {"score": {"$ref": "#/$defs/float", "x-order": 1}}Such a member carries no x-decimalPlaces and is not a Quantity, so none of
the exact-decimal machinery applies to it. The shipped DynamicForm.qml
renderer draws the plain TextField and encodes its text as a JSON number,
which it needs a branch of its own to do: the plain-text fall-through
(JSON.stringify(text)) would submit {"ratio":"3.5"} where the schema asks
for {"ratio":3.5}, and a TextField applies no validation of its own, so
{"ratio":"banana"} would be submitted just as readily and the form would
report ready for it.
The encoding is:
- Locale-normalised first, exactly as a
Quantity's entry is: the decimal separator and the digit grouping are the display locale's, and neither belongs in the JSON number ("1,000.5"→1000.5in a locale that groups on comma). - Grammar
-?\d+(\.\d+)?on the normalised text. No exponent, no trailing separator, no bare fraction: a spelling outside it has no literal, so the field is not engaged and the form is not ready — it is never encoded as a string instead. - Digits carried through as typed, not round-tripped through a JS number,
which would re-spell a long entry in exponent form and round it at the
seventeenth digit. Only the leading-zero run is removed, because JSON forbids
it:
"007.50"→7.50. Trailing fraction zeros are kept — re-spelling the fraction is not the encoder's business. - Gated on the declared range and on
multipleOf, like an integer field. For a plain member the range is the one glaze stamps on the type, so a value afloatcannot hold is refused by the same check that enforces aFieldMetabound.allFieldBoundsSatisfieddoes not check adoublemember — it readsQuantity, baremath::Rationaland integral members only — butreadyis a claim about the payload satisfying the schema, and the schema states the bound.
Three neighbouring shapes are numeric too and encode differently, which is why
the renderer asks in this order — Quantity, then integer, then plain
number:
| Member | Schema | Encoding |
|---|---|---|
Quantity<U, Dec> |
"type": ["object","null"], x-decimalPlaces, ExtUnits |
exact {num,den,dp}, assembled from the typed digits |
| an integral member | "type": "integer" (+ x-exactMinimum/x-exactMaximum past 2^53) |
bare integer, gated on the exact string bounds |
double / float |
"type": "number" |
bare JSON number, as above |
x-displayDecimals is an entry limit. When a plain number declares one
(FieldMeta::decimals, see Display unit and decimals),
an entry with more fraction digits than that has no literal, exactly as an
over-precise Quantity entry has none: it is refused, never rounded, because a
rounded value is one the user did not type. The encoding stays a bare JSON
number. The field descriptor carries the count as decimals (with
decimalsDeclared telling a slot whether 0 was declared or merely
defaulted), and the placeholder spells it ("0.000").
A generated Quantity property is therefore not "number" at all. The order
still matters, because a decorated schema can put x-decimalPlaces on a
property whose type is "number" — a precise field spelled the plain way. The
declared precision wins there, and the field keeps the exact encoding.
A bare math::Rational member is not in this family: its schema is an
inline object of num/den/dp with no x-decimalPlaces, so it is
unrepresentable — the one object-typed member whose C++
declaration looks scalar.
A nullable member whose underlying type is emitted as a definition rather than
inline — std::optional<std::int64_t>, or a std::optional<T> over a strong id
— produces neither a type key nor a top-level $ref:
"optI64": {"anyOf": [{"$ref": "#/$defs/int64_t"}, {"type": "null"}]}A renderer that resolves only a top-level $ref sees no type at all here, so
every field-kind flag is false and the value takes the plain-text path — a
quoted string the server rejects with parse_number_failure. DynamicForm.qml
therefore resolves through anyOf (and through oneOf, which takes the same
shape when a hand-written or evolved schema spells nullability that way): it
takes the first branch whose type is not "null", follows a $ref inside it,
and merges the result under the property's own keys, so the field is typed by
T and picks up T's constraints such as minimum/maximum. Integers on this
path are emitted as bare, exact numbers — the payload is assembled as JSON
text, never round-tripped through JSON.parse, so values beyond 2^53
(including INT64_MAX) survive intact.
This collapse applies only to branches that differ in nullability. A
oneOf/anyOf whose branches differ in value is a closed set, not a nullable
type, and is described next — collapsing one to its first branch would both
discard the alternatives and leave that branch's const masquerading as the
field's own pinned value.
A C++ enum class member that declares a glz::meta with glz::enumerate is
fully described by the schema. That declaration is the same one that makes the
enum travel as its enumerator name rather than as its underlying integer, so
in practice every enum a form can meaningfully render carries it. glaze emits
such a member as a oneOf of const alternatives, each carrying its own
title (the enumerator name, which is also the name its reader accepts):
"role": {"type": "string",
"oneOf": [{"title": "Viewer", "const": "Viewer"},
{"title": "Member", "const": "Member"},
{"title": "Manager", "const": "Manager"}],
"x-order": 2, "title": "Role"}This is standard JSON-Schema vocabulary, not an x-* extension: no morph key
declares it and none is needed. A renderer recognises the shape by the property
holding a oneOf/anyOf in which every branch bar {"type": "null"}
carries a const. One branch without a const and it is not a closed set —
that is the nullability shape above, and a partial list would be worse than no
list at all. The bare JSON-Schema enum keyword ({"enum": ["a", "b"]}), which
glaze does not emit but a hand-written schema may, states the same thing and is
read the same way.
Two obligations follow, and DynamicForm.qml meets both:
- Draw a selection control, not a text field. The alternatives'
titles are already the human labels, and the set is closed, so this is the same combo box aChoicedraws — the only difference is that the options are in the schema rather than behindx-optionsAction, so there is no options fetch and no round trip. Anullbranch is not offered as a choosable value: leaving the field blank is how an optional member is declined. - Refuse a value outside the set. Membership is decidable on the client
here — the schema states the whole set — so a value outside it must leave the
form not ready, exactly as a
booleanfield refuses anything buttrue/false. This is what distinguishes a closed set from aChoice, whose option list is a server snapshot that may already be stale and whose membership verdict therefore belongs to the server (see choice.md, "Validation & staleness").
The value on the wire is the enumerator name as a JSON string — "role":"Manager"
— which is what glaze's enum reader accepts; it refuses any other name with a
parse error, so a renderer that submitted free text was relying on the server to
say what the client already knew.
An enum without a glz::meta is refused at compile time. Without the
declaration, glaze would emit a $ref to a $defs entry that is the six-way
wildcard {"type": ["number", "string", "boolean", "object", "array", "null"]},
naming neither the enumerators nor even a single type — the shipped
DynamicForm draws that wildcard as a checkbox, reporting the form ready for a
value nobody chose. schemaJson<A>() therefore static_asserts on
glz::glaze_enum_t for every enum class member it reaches, so a rung that
declares one without glz::meta/glz::enumerate fails to build rather than
shipping a form that lies about being ready.
minimum and maximum are standard JSON-Schema vocabulary, stamped by glaze.
They are JSON numbers, and a renderer reaches them by parsing the schema —
every shipped app does JSON.parse(controller.schemasJson). JavaScript numbers
are IEEE-754 doubles, so any bound above 2^53 loses precision at that moment:
schema maximum for an int64_t field: 9223372036854775807
after JSON.parse into a JS number: 9223372036854775808 (rounded up)
That breaks the client-side gate at exactly the value it is closest to failing
on. INT64_MAX + 1 compared against a maximum rounded up to
9223372036854775808 is judged equal, not greater, so the renderer's own
validation admits an out-of-range value. Nothing is corrupted — the payload is
assembled as JSON text and keeps the exact digits, and the server rejects it
with parse_number_failure — but the client claimed a value was valid that
never was.
schemaJson<A>() therefore also emits the bound as an exact decimal string,
which JSON.parse cannot round. A renderer that validates integer input should
prefer x-exactMinimum/x-exactMaximum when present and fall back to the numeric
minimum/maximum otherwise. The shipped DynamicForm.qml compares digits
directly in that case, since no JS number can hold the bound.
Two deliberate limits:
- Emitted only above 2^53. An ordinary bound (
int32_t, aRangedslider, a hand-writtenmaximum: 10) loses nothing to a double, so its schema is byte-for-byte what it was before this key existed. Only the definitions that genuinely need it —$defs/int64_t,$defs/uint64_t— carry the companion. - The numeric bound stays. The companion is additive:
minimum/maximumremain exactly as glaze emitted them, so a renderer that ignores the new keys behaves precisely as it did before, per the versioning stance below.
Note the companion sits wherever the bound sits. For a std::int64_t
member that is the $defs entry the property's $ref points at, not the
property node — a renderer reads it from the merged node after resolving the
$ref (or the non-null anyOf branch), the same way it reads type.
The emitted schema is unversioned. There is no $id, $schema version
marker, or morph-specific version field anywhere in the output — a renderer
cannot detect at runtime which revision of this vocabulary a schema was produced
against. The vocabulary is therefore treated as a stable framework contract:
changing the semantics of any key above (renaming it, changing its type, or
altering how a value is interpreted) is a breaking change and ships only in a
breaking framework release. Adding a new, optional x-* key that older
renderers can safely ignore is not breaking.
The schema contract above is renderer-agnostic; morph ships one reference renderer for it, Qt/QML, as a reusable component rather than example code.
-
src/qt/formsbuilds the QML moduleMorphForms(CMake targetmorph_forms_module,qt_add_qml_module(... URI MorphForms VERSION 1.0)):DynamicForm.qml(theRepeater-over-fieldsform renderer:$refandanyOfresolution/dual-read, the closed-set selection control (see Closed sets), the exact rational digit arithmetic, the unit selector, the required-field submit gate, the options-fetch, layout/ grouping into sections/tabs, the widget-hint controls — textarea, slider, radio group — the comma-separated-with-validation"array"-typed field control (see Array fields), the explicit submit mode (see Explicit submit mode), and the localisation dual-read),DateTimePicker.qml(manual ISO-8601 entry plus a calendar/time popup),SlotRegistry.qml(below), andI18nCatalog.hpp/.cpp(aQObject/QML_ELEMENTin-memoryTranslationProviderrealization — see Localisation — shipped alongside the renderer rather than left in a demo, since it is model-agnostic andDynamicForm'scatalogproperty consumes it structurally, not by name). It builds whenever-DMORPH_BUILD_FORMS_QML=ON, independent ofMORPH_BUILD_EXAMPLES— an app depends on it directly (target_link_libraries(... morph_forms_moduleplugin)plusimport MorphFormsin its own QML) instead of copying or forking it. Installed, it is theforms_qmlcomponent:find_package(morph CONFIG REQUIRED COMPONENTS forms_qml qt_forms)andmorph::forms_qmlplugin. The install carries the backing library, the plugin and the object libraries a static QML module needs (its compiled resources and the plugin's static initialiser — without them an application links and then finds noMorphFormsat run time), plusqmldir/.qmltypes/sources underMORPH_INSTALL_QMLDIRfor tooling, exported asmorph_QML_IMPORT_PATH. The installedqmldir'slinktargetnamesmorph::forms_qmlplugin, the name a static-Qt build's QML plugin import resolves.scripts/check_forms_qml_install.sh(CI jobinstall-export-forms-qml) builds and runs an application against an install, and proves the plugin is what it measured by running the same application without it. -
include/morph/qt/forms/forms_controller_core.hpp(componentqt_forms, which also ships theqt_executor.hppit includes, so an install withoutMORPH_BUILD_QTstill compiles it) shipsmorph::qt::forms::FormsControllerCore<Model>, a header-only, model-agnostic template (noQ_OBJECT— Qt cannot register a class template for QML) that owns or composes over theBridge/BridgeHandler<Model>/QtExecutorwiring an app's ownQObject/QML_ELEMENTcontroller subclass forwards to. Two constructors decide who owns theBridge:FormsControllerCore(schemasJson)builds and owns a privateThreadPoolExecutor+QtExecutor+Bridgeover aLocalBackend— the convenient default for a demo or an app with noBridgeof its own.FormsControllerCore(Bridge& bridge, IExecutor* guiExec, schemasJson)composes over a caller-suppliedBridge/executor instead of building a second, always-local one — the caller decides the deployment mode (LocalBackend,SimulatedRemoteBackend,QtWebSocketBackend, ...), and a laterbridge.switchBackend(...)on that sameBridgeis still reachable through this core's handler (the handler re-registers itself automatically, exactly like any otherBridgeHandler).bridgeandguiExecmust outlive the core.
It exposes
schemasJson(),submitIfValid(actionType, bodyJson, onReply, onError), andfetchOptions(optionsAction, bodyJson, onReply, onError)— both operations dispatch generically viaBridgeHandler::executeJson, so an app's controller never hardcodes one action, andfetchOptions'sbodyJsonis a true pass-through ("{}"for an independentChoice, or{parentField: value, ...}for a dependent one — see Choice — server-sourced picklist) rather than always empty;examples/forms/gui_qml/FormsController.hppis the ~20-line reference wrapper (naming its own model type, since Qt cannot register the template itself), still using the owning constructor since the demo has no pre-existingBridgeto compose over. -
examples/forms/gui_qmlis a consumer of the shipped module, not its home: its ownLabFormsDemoQML module carries onlyMain.qmland theFormsControllersubclass naminglab::LabModel;Main.qmlimportsMorphFormsforDynamicForm/I18nCataloglike any other consumer would. -
include/morph/qt/forms/multi_model_forms_controller_core.hpp(same component, same install story) shipsmorph::qt::forms::MultiModelFormsControllerCore<Sharing, Model...>, the multi-model sibling ofFormsControllerCore<Model, Sharing>for a rung whose forms span more than one registered model (bookmarks::gui::FormsBridge, whose forms serveAuthModel,BookmarkModelandTagModel, is the shipped example). SameschemasJson()/submitIfValid()/fetchOptions()surface, overmorph::qt::bridge::MultiModelBridgeCore<Sharing, Model...>(include/morph/qt/bridge/multi_model_bridge_core.hpp) instead ofGenericModelBridgeCore<Model, Sharing>: it composes oneGenericModelBridgeCore<Model, Sharing>perModelin the pack and routes a submitted action-type id to whichever one serves it, viaGenericModelBridgeCore::servesAction(which forwards toBridgeHandler::servesAction) — a pure existence check overActionExecuteRegistry, the same registryexecuteJsondispatches through — rather than a hand-writtenactionType -> Modeltable. Models are tried in the order the pack declares them; an action id registered on more than oneModelin the pack is a configuration bug asserted in debug builds, not a case routed silently. An unrouted action type resolvesonErrordirectly with"no model in this client serves action '<id>'", the same wording every hand-written router before it agreed on independently.
This is packaging and factoring only: no x-* key changed, and a plain
single-action form renders identically to before the renderer was extracted.
DynamicForm connects to the controller through two Connections blocks,
not one, because only one of the two signals is universal:
-
replyReceived(actionType, ok, payload)is required of every controller. Its block is strict, so a handler there that matches no signal on the target is a misspelling and the engine reports it. -
optionsReceived(optionsAction, ok, payload)is optional. It exists only on a controller that serves aChoicefield; a controller that serves none deliberately declares neither it norfetchOptions()(bookmarks::gui::FormsBridgeandpastebin::gui::FormsBridgeeach carry the reasoning: an unusedfetchOptions()would be a stub with nothing to call it). Its block gates its target on the signal being declared —form.controller.optionsReceived !== undefined, elsenull— so a controller that omits it is never connected to and the absence is not a warning. Without the split, every form instance warns once aboutonOptionsReceivedas soon as a conforming choiceless controller is attached, which forces any GUI test asserting "no QML warnings" to tolerate that exact text.The gate is what makes the block optional, not
ignoreUnknownSignals. A controller that does declareoptionsReceivedis connected to strictly, so a misspelling of the handler is still reported.ignoreUnknownSignals: truewould silence that too, and the silence is expensive: the options never arrive, everyChoicecombo box stays empty, the form never reachesready, and nothing is logged.
src/qt/forms/tests/tst_DynamicFormChoicelessController.qml pins both halves:
a choiceless controller loads with no warning, a Choice-serving one still
receives its options, and a target missing replyReceived is still reported.
DynamicForm.schema takes the parsed schema however it is supplied — a
declarative QML binding (schema: controller.schemas[actionType]), an initial
property, a setProperty from C++, or createTemporaryObject(component, parent, {schema: ...}). An assigned value round-trips through QVariant,
which turns each of the schema's arrays into a QVariantList rather than a JS
array; the renderer re-reads the schema as plain JSON once, at the property, so
an array-valued type (["integer","null"]), the anyOf-over-$ref collapse
and closed-set recognition all read the same either way. The same schema
supplied both ways yields the same field descriptors and the same submitted
body — asserted in src/qt/forms/tests/tst_DynamicFormSchemaAsVariant.qml,
which builds one form each way and compares them against each other.
The one thing that does not survive the QVariant boundary is JSON key
order: the map it converts through is sorted, and the declaration order is
gone before the renderer is reached, so no renderer can recover it. This is
the general rule x-order already exists for — "renderers lay fields out in
ascending x-order, not in JSON key order", above — and schemaJson<A>()
emits x-order on every property, so a generated schema is unaffected. A
hand-written schema that omits x-order leaves its fields tied, and the
sort that orders them is Array.prototype.sort, which QML's engine does not
guarantee to be stable: measured on Qt 6.11.2, four all-equal elements come
back reordered. So a tied schema lays out in an order that is neither
declaration order nor key order, bound or assigned. Give every property an
x-order.
One other value JSON cannot carry: a non-finite minimum/maximum. Only a
hand-authored QML object literal can declare one — schemaJson<A>() never
emits it, and no JSON text can spell it — and the re-read turns it into null.
The renderer reads a bound that is not a finite number as no bound declared,
which is what maximum: Infinity already meant, and matches JSON Schema giving
a null numeric keyword no meaning. Without that, a null bound would read as the
bound 0 and reject every positive value.
An editing flow opens a saved record, reads its DTO and wants the form to show
it. DynamicForm.prefill(values) takes the payload as an object keyed by wire
name (a parsed action or section DTO); prefillFromJson(text) takes its JSON
and parses it with JsonExact, so an id past 2^53 arrives digit for digit.
Either replaces the whole draft — a member absent from the payload starts
blank, as after resetFields() — and returns false, changing nothing, for
input that is not an object.
Each value becomes the text the built-in control for that member would hold,
through decodeFieldValue(field, value), the inverse of encodeFieldText:
| Member | Wire value | Draft text |
|---|---|---|
Quantity |
{num,den,dp} |
exact digits at the field's canonical x-decimalPlaces, rounded half-up, in the display locale (2450.50, 2450,50 in de_DE); the unit selector returns to the canonical unit |
| plain number | JSON number | never exponent form; padded to x-displayDecimals when declared, never rounded to it |
| integer | JSON integer | its exact digits |
Timestamp |
ISO-8601 (zone designator optional, read as UTC when absent) | wall clock in displayOffsetMinutes |
boolean |
true/false |
"true"/"false" |
closed set / Choice |
the value | its valueJson |
std::vector<T> |
array | entries joined by ", " |
std::vector<Row> |
array of objects | the rows as {member: cellText}, each cell decoded by the row member's own descriptor |
| string | string | itself |
A value whose shape does not match its field decodes to "" (blank). The
round trip is the contract: prefilling a form from a payload and editing
nothing assembles the same payload, in the canonical spelling the encoders
produce. Every drawn control re-seeds from the new draft (prefillRevision),
a fetched Choice re-selects its row whenever its options arrive, dependent
Choices are re-fetched for their prefilled parents, and slots see the values
through fieldText / rows.
A prefill never submits, in auto-submit mode included: the final
revalidation runs inside the programmaticEdit window, so a ready prefilled
form waits for the user. src/qt/forms/tests/tst_DynamicFormPrefill.qml pins
the round trip for every member kind, locale and zone, slots, the fetched
Choice, and the no-submit rule; removing the control re-seed reddens 5 of its
11 cases and a wrong Quantity decoder 8.
DynamicForm.ready is a claim about the payload: true only when the body
the form would submit satisfies the schema the form was generated from. It is
deliberately not the weaker "every control the renderer drew is filled". Under
that reading the flag would be true for a body the action must reject, and the
rejection would surface at the action boundary rather than in the form, where the
user could still correct it. Two obligations stated earlier are instances of the
same rule: a value outside a closed set
leaves the form not ready even though the combo box holds it, and a boolean
field refuses anything but true/false.
So a member this renderer has no encoding for keeps the form short of
ready. DynamicForm.qml calls such a member unrepresentable, and recognises
two shapes:
- an object-typed member that no typed control claims — a
nested aggregate, whose one scalar
control collects text where the schema asks for an object. A bare
math::Rationalmember is this shape too, and is the instance least likely to be expected: its C++ declaration is a scalar and its schema is an object ofnum/den/dp. Wrapping it in aQuantity— or giving the field anx-decimalPlaces— is what hands it the exact-decimal control that encodes that shape; - an array whose
itemsare objects (or arrays), which takes thetype: "array"control and encodes each entry as a JSON string. A control was drawn and the member is unrepresentable anyway, which is precisely why "a control was filled" cannot be whatreadymeans.
A Quantity and a Choice are "type": "object" in the schema too and are
not unrepresentable: their own controls encode the shape their schema asks
for. The test is whether an encoding exists, not what the JSON type is.
Two seams carry the reason, because a gate that only says "no" is not actionable:
| Read | Where | Meaning |
|---|---|---|
fields[i].unrepresentable |
field descriptor | Why no control here can collect what the schema asks for, or "". A property of the schema, so it is readable before anything is typed — a caller can decline the form up front. |
unrepresentableReason |
form | "<wire name>: <reason>" for the member submission is currently stuck on, or "". Written by revalidate() in the same pass that writes ready, so the two cannot disagree. Empty while the form is ready and while it is merely unfilled: a blank required field or an unsatisfied rule is the ordinary submit gate, which the user can act on. |
The form's status label shows that reason in place of "fill the required (*)
fields" — advice no input can act on — and, being the label the
accessibility slice mirrors into
Accessible.description, announces it rather than merely tinting it.
A slot can supply the missing encoding for a collection of objects or a
nested object. When a registered slot claims a top-level std::vector<Row>
member or a top-level struct member, unrepresentable is "" for it and the
form encodes the rows or members the slot writes — see
Collections of objects and
Nested objects. The
descriptor therefore depends on the slot registry as well as on the schema; the
fields binding reads SlotRegistry.revision, so a slot registered after the
form was built is picked up.
An unrepresentable member the payload may legitimately omit does not block
submission. Optional and left blank, it is simply absent from the body, and
that body is one the schema accepts; unrepresentable still names it on the
descriptor, so the member is declined rather than dropped silently. Typing into
its control is what makes the form unready, since that text has no encoding.
What this does not decide is what the renderer should eventually draw for
such a member — a sub-form, a refusal with a diagnostic, or the flattening it
does today. The readiness answer is the same under all three, so it does not
wait on that one. src/qt/forms/tests/tst_DynamicFormNestedAggregate.qml pins
both directions: the unready cases with their reasons, and a flat form
(integer, string, array-of-string, Quantity) that names nothing
unrepresentable and reaches ready.
A renderer proves it honors the contract above by consuming a schema corpus and satisfying a set of expected-behavior assertions — the executable form of this document's "normative" claim.
- C++ fixture corpus and drift guard
(
tests/test_forms_conformance_corpus.cpp): five fixture action types — plain scalars +required(CFScalarsAndRequired), aQuantitywith convertible alternatives (CFQuantityAlternatives), aChoice(CFChoiceField), aTimestamp(CFTimestampField), and two members of the sameQuantitytype sharing one$def(CFSharedDefFields) — each asserted against the real, generatedschemaJson<A>()output (never hand-authored), so a change tomergeSchemaExtras/schemaJsonthat altersx-order,required,x-decimalPlaces,x-unitAlternatives,x-optionsAction/x-optionValue/x-optionLabel,format, orExtUnitsis caught here as a failing assertion (the corpus "drift guard"). - QML functional assertions (
src/qt/forms/tests/tst_conformance.qml) hand-author schemas mirroring each C++ fixture by name and run them through the shippedDynamicForm: fields render inx-order; submission is blocked until everyrequiredfield is engaged and enabled once they are; aQuantitypayload is{num,den,dp}exact and a unit switch recomputes it exactly (no float drift); aChoicedescriptor carries its declaredx-optionsAction/x-optionValue/x-optionLabel; aTimestamprenders as a date-time control and gates on ISO-8601; two properties sharing one$defeach keep their ownx-orderwhile resolving the sameExtUnits. The options-fetch itself — an independentChoiceexecuting its options action with an empty body, and a dependent one (x-optionsDependsOn) with{parentField: value, ...}— is asserted separately, insrc/qt/forms/tests/test_forms_controller_core.cpp(a Catch2 + Qt executable coveringFormsControllerCore<Model>directly), sinceDynamicFormnever calls the options action directly — its controller does. - Accessibility slice (
src/qt/forms/tests/tst_conformance_accessibility.qml): every control exposes an accessible name (the wire key —titlefrom Field metadata, when declared, is the visible label but the accessible-name fallback is always the wire key), a required field's accessible description announces it, focus order followsx-order, and every control (choice combo, radio group, date/time picker, text field, multiline text area, slider, unit selector, and the calendar popup, which gained arrow-key day navigation plus Enter/Escape for exactly this) is keyboard-operable. - Negative assertions (
src/qt/forms/tests/tst_conformance_negative.qml, with test-only doublesBrokenOrderForm.qml/BrokenQuantityForm.qml, never shipped in theMorphFormsmodule): a renderer that ignoresx-orderfails exactly the field-order assertion and no others; a renderer that silently rounds an over-preciseQuantityentry instead of rejecting it fails exactly the exact-payload assertion and no others — proving the kit's assertions are specific, not all-or-nothing.
Scope note. The corpus above covers exactly the keys this document's
renderer contract currently defines, plus the x-widget/SlotRegistry keys
below — informally, "Tier-1": the per-action x-* schema vocabulary this
document specifies. Two numeric-bound families sit in the contract table but
outside the five-fixture corpus, each carrying its own matched C++/QML pair
instead: x-exactMinimum/x-exactMaximum
(tests/test_forms_exact_bounds.cpp + tst_DynamicFormExactBounds.qml) and
the declared minimum/maximum/multipleOf
(tests/test_forms_field_bounds.cpp + tst_DynamicFormFieldBounds.qml). Both
are additive per-field keys that no corpus fixture declares, so adding one
changes no fixture's generated schema — which is why the drift guard has
nothing to say about them and a dedicated pair does. It does not include a wizard/app-shell fixture
(w-*/app-*): although the emitters for those "Tier-2" keys (the
wizard/app-shell layer built atop Tier-1, one level up the composition —
workflows_navigation.md) now exist
(morph::flows::wizardSchemaJson/morph::app::appSchemaJson, see
workflows_navigation.md), no conformance-kit
fixture exercises them yet — that coverage is deferred to future work,
exactly as this corpus already treats views (below) separately rather than
as a sixth CF* fixture. The v-* view-schema layer
(morph::views::viewSchemaJson, views.md) is implemented;
its own renderer-behavior coverage lives in
src/qt/forms/tests/tst_collectionview.qml rather than this five-fixture
corpus (a view composes existing action schemas rather than introducing new
per-field schema keys, so it does not need a sixth CF* fixture type here).
A field's control is chosen by the renderer's built-in logic (isChoice → combo
or radio group, isQuantity → number + unit selector, format: date-time →
date/time picker, x-widget: "textarea"/"slider" → multiline/ranged
controls). An app that wants a different control for one field, one unit, one
x-widget, or one JSON type does so through a client-side registry, without
forking the renderer:
-
x-widget(optional property-level key). A hint naming a control variant when the type alone is ambiguous — e.g."textarea","slider","radio"(already dispatched on by the renderer's own widget-hint controls, see Widget hints), or an app-defined id such as"slider"/"rating"a registeredSlotRegistryslot recognises. It is read with the same dual-read as every other property-level key (opt(raw["x-widget"], p["x-widget"])). Absent, it resolves to""and never matchesSlotRegistry'sbyWidgettier — purely additive and ignorable. -
SlotRegistry(QML type, moduleMorphForms, entirely client-side). A lookup a host app populates at startup:byField(action, field, component),byWidget(xWidget, component),byUnit(unitAscii, component),byKind(kind, component),byType(jsonType, component), andresolve(action, field, xWidget, unitAscii, jsonType, kind), which returns the highest-priority match ornull(kindis optional; the five-argument call resolves as before). Resolution order is field →x-widget→ unit → kind → type → built-in default.DynamicFormgains aslotRegistryproperty (nullby default — no behavior change for an app that never sets it); when a field resolves to a registeredComponent,DynamicFormloads it via aLoaderand hides its own built-in control for that field (every built-in control — combo, radio group, date/time picker, text field, text area, slider, unit selector — is gated on theLoader'ssourceComponentbeingnull). A registered slotComponentimplements one small contract: it declaresproperty var fieldandproperty var setValue, both assigned byDynamicForm'sLoader.onLoaded—fieldis the resolved, merged def+property descriptor, andsetValue(text)is the same set-value path (setFieldValue) the built-in controls use, so an override participates in the required-gate and auto-fire without special-casing.A slot may also declare any of six optional members, each assigned only when declared (so an existing slot is unaffected):
Member Assigned as Use fieldTexta binding to the field's retained text Seed and track the control's value. A prefill, resetFields(), asetFieldValuefrom code and a tab switch that rebuilds the slot all reach it; without it a slot sees only what it wrote itself.rowsa binding to the rows of a collection of objects, as a JS array of {member: cellText}([]when none)Draw the grid. setRowsfunction (rows)Write the rows; equivalent to setValue(JSON.stringify(rows)).objectValuea binding to the value of a nested object, as a JS object of {member: cellText | nestedValue}({}when blank)Draw the sub-form. setObjectfunction (value)Write the object; equivalent to setValue(JSON.stringify(value)).formthe DynamicFormitselfReach encodeFieldText(field, text, 0)(does this cell encode?) and the rest of the form's public surface.The bindings re-evaluate on
rulesRevision, whichrevalidate()bumps after every write to the draft. A slot that claims a collection of objects or a nested object is also what makes that member representable — see Collections of objects and Nested objects.SlotRegistry.revisionis bumped on everyby*()call and read insideresolve(), for the same reasonI18nCatalog.revisionexists:_byField/_byWidget/_byUnit/_byTypeare plain objects mutated in place, which does not by itself notify a binding that already read them.
byKind — one host control per kind of control. The JSON type does not
name the control a field needs: a Quantity and a nested object are both
"object", a Choice is "integer", a closed set and a Timestamp are
"string". Every field descriptor therefore carries kind, the control this
renderer would draw, decided in the order its encoder is chosen:
kind |
Member (schema shape) |
|---|---|
objectArray |
std::vector<Sub> (array whose items are an object) |
array |
any other std::vector<T> |
enum |
a closed set (oneOf of consts, or enum) |
choice |
a Choice (x-optionsAction) |
datetime |
a Timestamp (format: "date-time") |
date |
format: "date" (drawn as a plain text field by the built-in renderer) |
quantity |
a Quantity, or any property with x-decimalPlaces |
integer / boolean / number |
type of that name |
object |
a nested aggregate, plain or std::optional (unrepresentable without a slot; see Nested objects) |
string |
everything else |
A host registers one kit component per kind (byKind("quantity", …),
byKind("choice", …), …); a field, x-widget or unit registration still wins,
and byType remains the fallback for a kind with none. Pinned by
src/qt/forms/tests/tst_SlotRegistryByKind.qml; removing the tier from
resolve() reddens 4 of its 5 cases.
Field slots replace a field's control. Everything around the controls — the
caption, the help line, the section card, the tab bar, the heading, the status
line, the submit button, the JSON preview and the reply line — is chrome, and a
host whose visuals come from one UI kit needs to replace that too, or its forms
stay half-restyled. SlotRegistry.byChrome(role, component) registers one
Component per role, resolveChrome(role) returns it (or null), and
DynamicForm loads it in place of the built-in for that role — the built-in
is hidden, not drawn beside it:
| Role | Replaces | Members assigned (each only if declared) |
|---|---|---|
fieldLabel |
the caption and red * of every field |
field, text (label), required (live, includes requiredWhen), invalid (live: typed text that does not encode) |
fieldHelp |
the help line of a field that has one | field, text |
section |
a titled "section" group (and "accordion", when no accordion chrome is registered) |
title, kind, section ({title, kind, fields}); must declare contentItem |
accordion |
a collapsible "accordion" group |
as section; collapsing is the chrome's own |
tabset |
a run of consecutive "tab" groups |
tabs ([{title}]), reads the chrome's own currentIndex; must declare contentItem |
header |
the action-type heading | text |
status |
the "fill the required (*) fields" / ready line | text (DynamicForm.statusText), ready, reason (unrepresentableReason), explicitSubmit |
submitButton |
the explicit-mode Submit button | ready, submit() |
preview |
the monospace JSON preview | text (previewLine) |
result |
the ok:/err: reply line |
text, ok |
CollectionView and WizardView read the same registry for chrome roles of
their own (collectionHeader, collectionRow, confirmDialog,
editorDialog; wizardHeader, wizardNav — see
views.md and
workflows_navigation.md), and hand it on
to every DynamicForm they embed. DateTimePicker has no chrome of its own: a
host replaces the whole picker with a field slot (byKind("datetime", …),
#812).
Every chrome item is also offered form (the DynamicForm). Values that change
are assigned as bindings. A role with no registration keeps the built-in
exactly, so an app that registers nothing sees no change. To remove a piece
of chrome, register an empty Item; preview and result chrome are loaded
whatever their text, so an app that wants them decides itself when an empty one
shows.
Container chrome hosts the fields; it does not re-create them. For
section, accordion and tabset, the form creates its field grid
(gridColumns columns, x-colspan honoured, the same field delegates) as a child of the
chrome's contentItem, which is expected to be a Layout — a ColumnLayout is
enough. The built-in grid's Repeater is emptied under a chrome, so each field
exists once and its field_<name> objectName stays unique: prefill,
resetFields() and every test that finds a control by name keep working. A
tabset chrome owns currentIndex; the form shows the selected tab's fields
and, as with the built-in tab bar, rebuilds them on every switch (they re-seed
from fieldValues). The implicit untitled "flat" group has no chrome.
submit() goes through DynamicForm.submit(), whose ready guard applies to a
chrome button exactly as to the built-in one.
DynamicForm itself is a Frame: its outer border and padding are the
Frame's background and padding, which a host sets on the instance
(background: null, padding: 0) — no slot is needed for them. The controls a
field slot does not replace are the style's own QtQuick.Controls types; no
style is imported, so they follow whichever style the application selects.
src/qt/forms/tests/tst_DynamicFormChrome.qml pins each role — values handed
over, built-in hidden, fields created once inside a container — and a form with
every role registered showing no built-in Label, Button or TabBar.
Restoring the built-in section Repeater under a chrome reddens the
fields-created-once case.
The registry never appears in the schema or on the wire — two renderers of the same schema may register different slots. This is the "escape hatch always available" design principle (above) in practice: swap one control without forking the renderer.
The schema stays one cached, un-localised instance per type (see One cached schema per type — no localisation): translation is a renderer-side catalog lookup over stable, mechanically derived message keys, never a per-locale schema variant. Two small header-only libraries carry this:
morph::forms::i18n(include/morph/forms/i18n.hpp) — the key derivation vocabulary.morph::render(include/morph/render/i18n.hpp,include/morph/render/locale_format.hpp) — the renderer-side catalog seam and the locale numeric-entry contract.morph::renderis client-side only and never appears on the wire.
A key is derived from identifiers the schema (or the actionType label a
renderer already has) already carries — no declaration needed in the common
case:
| Text slot | Derived key | Function |
|---|---|---|
| field label / help / placeholder | <actionTypeId>.<wireField>.label / .help / .placeholder |
morph::forms::i18n::fieldKey(actionTypeId, wireField, FieldSlot) |
| layout group title | <actionTypeId>.group.<index> |
groupKey(actionTypeId, groupIndex) |
| cross-field rule message | <actionTypeId>.rule.<index> |
ruleKey(actionTypeId, ruleIndex) |
| wizard title / step title | <wizardId>.title / <wizardId>.step.<index>.title |
wizardTitleKey(wizardId) / wizardStepTitleKey(wizardId, stepIndex) |
| app title / menu label | <appId>.title / <appId>.menu.<index>.label |
appTitleKey(appId) / appMenuLabelKey(appId, menuIndex) |
actionTypeId is ActionTraits<A>::typeId(); wireField is the member's
reflected wire key (the same name mergeSchemaExtras iterates via
forEachNamedMember); group/rule/step/menu indexes are the 0-based position
in their respective schema arrays.
The field key is assembled from three named pieces rather than formatted in
one place, and a renderer reproducing this scheme in another language needs
all three: fieldKeyStem(actionTypeId, wireField) builds the
<actionTypeId>.<wireField> stem, fieldSlotName(FieldSlot) spells the slot
suffix ("label", "help", "placeholder"), and withSlot(stem, slot)
joins them with a .. fieldKey() is withSlot(fieldKeyStem(...), slot) and
explicitFieldKey() is withSlot(i18nKeyOverride, slot) — which is why an
override replaces the stem and nothing else. None of these keys are written into the
schema — a renderer derives them itself from data it already has (the schema
plus the actionType label it is rendering under), so declaring nothing
changes zero bytes of any schema.
Declare to override. A field declares an explicit key stem via
FieldMeta::i18nKey (see Field metadata),
emitted as x-i18nKey on its schema node; group, rule, wizard step, and menu
descriptors gain the same optional i18nKey member on their own types, owned
by each descriptor's own spec. For a field, the override replaces only
the <actionTypeId>.<wireField> stem; each of the three per-field suffixes
(.label / .help / .placeholder) still applies on top of it —
morph::forms::i18n::explicitFieldKey(i18nKeyOverride, slot) computes
"<i18nKeyOverride>.<slot>". For a group, rule, wizard step, or menu entry —
each of which carries exactly one piece of text — the override is the
complete key, used in place of the derived one.
// namespace morph::render — client-side only; never on the wire.
using TranslationProvider =
std::function<std::optional<std::string>(std::string_view key, std::string_view bcp47Locale)>;morph::render::resolveText(provider, bcp47Locale, explicitKey, derivedKey, schemaLiteral) resolves one display slot's text, most specific first: the
explicit key (when declared) is tried first, then the derived key, and a
miss at both falls back to schemaLiteral — the schema's authored title /
description / x-placeholder / group or step title, unchanged. A
default-constructed (empty) provider — no catalog installed — skips
straight to schemaLiteral, so an unconfigured renderer behaves exactly as
it did before this spec. morph ships the seam and this resolution algorithm
only; it defines no translation storage format — a host adapts whatever
catalog it already owns (Qt QTranslator/.qm, a JSON bundle, a database)
into the one TranslationProvider signature.
The examples/forms/gui_qml reference renderer hosts a concrete, minimal
realization: I18nCatalog (examples/forms/gui_qml/I18nCatalog.hpp), an
in-memory QObject catalog (QML cannot hold a std::function directly),
wired into DynamicForm.qml's resolveText/i18nFieldKey JS mirrors of the
functions above. It currently resolves only the field label/help/placeholder
slot — group-title i18n wiring for the already-implemented
Layout & grouping feature remains
future work. The wizard/app-shell layer
(workflows_navigation.md) is implemented, but its
QML renderer (WizardView.qml/AppShell.qml) does not yet accept an
I18nCatalog either, matching CollectionView.qml's own gap (see
views.md, "Limitations") — wizard/app-menu i18n wiring remains
future work. Cross-field rules (above) are implemented
but carry no translatable message text of their own — the x-rules
vocabulary is structural (kind/fields/when/value) only, so a renderer
builds any rule-violation message from that structure (or its own catalog
entry, per "Rule messages come from the catalog, not the wire" below), never
from a wire string.
Group membership is matched by index, never by translated text. A
field's x-section is the stable numeric handle into x-layout.groups; a
renderer translates a group's displayed title but places fields by index.
Rule messages come from the catalog, not the wire. For a rule the client can evaluate, the renderer shows its catalog message (falling back to a renderer-built neutral message from the rule's structure); canonical server-side error strings (error_handling.md) stay untranslated protocol vocabulary, surfaced only for conditions the client could not pre-empt.
Display formatting is the renderer's duty; the wire stays canonical:
-
Numbers.
morph::render::normalizeLocaleNumber(text, loc)(include/morph/render/locale_format.hpp) converts a locale-formatted entry ("1.050,25") to the canonical.-decimal textQuantity's exact digit routines already consume ("1050.25"); malformed input yieldsstd::nulloptrather than a best-effort guess.formatCanonicalNumber(canonicalText, loc)is the display-direction inverse, with display-only thousands grouping. The exactRational/Quantitydigit arithmetic (rational.md) never sees a locale-formatted string — the conversion happens at the control edge only.The locale facts travel as one aggregate, not as a row of positional views:
struct NumericLocale { std::string_view decimalSeparator = "."; std::string_view groupSeparator = ""; std::string_view negativeSign = "-"; std::string_view positiveSign = "+"; std::string_view zeroDigit = "0"; };
Both edges take it, and a call site names each fact with a designated initialiser:
normalizeLocaleNumber(text, {.decimalSeparator = ",", .groupSeparator = "."}). Three things this is for, and the first is not tidiness. The five facts were five adjacentstd::string_viewparameters, every one silently swappable with its neighbours, and the header carried a clang-tidy suppression forbugprone-easily-swappable-parameters— on each of the two functions and on the sign helper — with a paragraph of justification each. A sixth would have made that argument weaker, not stronger; the aggregate deleted all three suppressions instead, and the header is clean underbugprone-*with no suppression of that check anywhere in it. Second, the next locale fact (a percent sign, an exponent separator) is a new defaulted member rather than a seventh parameter. Third, and the reason it is worth the churn: the two edges take the same type, so "these two must agree" is structural rather than a convention a caller can get half right — and that convention is exactly what the two edges drift apart on. There is deliberately no back-compatible positional overload: two spellings of one call is how they drift. The QML mirror takes the parallel shape, an object literal with the same member names, so the two mirrors stay structurally identical.Every member is defaulted to its
"C"-locale spelling, so a caller that names none of them gets the identity transform in both directions.The digits are locale data too, carried as a base. A Unicode decimal digit set is ten contiguous code points — UAX #44 assigns
NdwithNumeric_Value0 through 9 in code point order — so a singlezeroDigitis sufficient and a ten-element table is not needed. Measured withQLocale::matchingLocalesunder Qt 6.11.2, over the same 711 locales:zeroDigitlocales e.g. U+0030 604 CU+0660 26 ar_BHU+06F0 19 fa_IRU+1E950 12 ff_Adlm_BFU+0966 8 bgc_INU+09E6 4 as_INU+11136 2 ccp_BDU+07C0 1 nqo_GNU+0F20 1 dz_BTU+1040 1 my_MMU+1C50 1 sat_INU+ABF0 1 mni_IN76 of 711, across eleven distinct sets. Two of them (Chakma U+11136, Adlam U+1E950) are outside the BMP, so one digit is four UTF-8 bytes on the C++ edge and two UTF-16 units on the QML edge — both scans therefore decode a code point rather than comparing a unit. The figure the issue could defend before this was 24, and it said so plainly: 24 was a count of locales whose negative sign is
U+061C U+002D, not a digit-set count. 76 is the measured one.Before this,
normalizeLocaleNumbercompared one byte against['0','9'], so a user of any of those 76 locales could not enter a number at all — a flat rejection, not a wrong value.Both edges move or neither does, and the round trip is what says so.
formatCanonicalNumberused to copy the canonical ASCII digits out unchanged. That is why the pair was self-consistent and the defect invisible from either side alone: display emitted the locale's separators and sign around ASCII digits, and entry accepted exactly that. Teaching entry to accept U+0665 while display kept emitting'5'satisfies a naive reading of the bug report and breaks the round trip this section requires. So the display edge emits the digit that far abovezeroDigit, and the property is stated in those terms:for every canonical
-?[0-9]+(\.[0-9]+)?text and everyNumericLocale,normalizeLocaleNumber(formatCanonicalNumber(canonical, loc), loc) == canonical, byte for byte, with every digit of the display text drawn from[loc.zeroDigit, loc.zeroDigit + 9].The second half of that sentence is load-bearing. The round trip alone does not fail if only the entry edge moved, because entry accepts ASCII digits too — so the test that pins this asserts the display text contains no ASCII digit as well as asserting the round trip, and it is that assertion which fails for the half-fix. Pinned over all eleven digit sets on both edges:
[morph591]intests/test_render_locale_format.cpp, and thetest_thePairRoundTripsThroughEveryMeasuredDigitSetfunction insrc/qt/forms/tests/tst_i18n.qml.Entry accepts the locale's digits and ASCII ones; display emits only the locale's. That asymmetry is the rule the signs follow, applied to digits: an ASCII
'+'is accepted in every locale because the locale's own spelling is on no keyboard, and an ASCII'5'is accepted in anar_EGlocale for the same reason — a user with an ASCII keyboard has to be able to type a number. It costs nothing, because the canonical output spells every digit in ASCII whatever the input spelled it, so no two accepted spellings can produce different values.An entry may not mix the two digit families.
"\u06655"— one Arabic-Indic digit and one ASCII digit — is malformed, not"55". This is a decision rather than a consequence, so it is written here and pinned by a test on both edges: neither a keyboard nor a display edge produces an interleaving, and rejecting it matches the existing strictness about a sign anywhere but the leading position. WhenzeroDigitis the ASCII"0"the two families are the same set, so nothing can mix and the rule is invisible — which is why it costs no existing caller anything.An empty or undecodable
zeroDigitreads as ASCII"0", the reading an emptynegativeSigngets and for the same reason: there is no locale without digits, so empty cannot mean absence, and a base of "nothing" would reject every entry the locale can produce. BecausezeroDigitdefaults to"0", every caller that does not name it is byte-identical to the five-positional-parameter version — asserted rather than assumed: a 1680-case sweep (14 locale configurations × 60 entries × both edges) run against the positional spelling and against this one produces identical output.All the locale facts are
std::string_view, notchar, because a real locale's separator is not always one byte: fr-FR groups with U+202F (narrow no-break space, 3 bytes in UTF-8) and several locales use U+00A0 (2 bytes). Typed aschar, neither could be expressed at all — a caller could only pass some single byte that never matched, so a perfectly valid"1 050,25"typed by a French user normalised tostd::nulloptand the control reported it malformed. An empty view means "this locale has no such separator".So is the negative sign. The same argument applies to the sign: both edges read
NumericLocale::negativeSign, matched and emitted as a whole string the way the separators are. Of the 711 localesQLocale::matchingLocalesreports under Qt 6.11.2, 77 spell it as something other than a bare ASCII'-':negativeSignlocales e.g. U+002D 634 CU+061C U+002D 24 ar_EGU+200E U+002D 9 ar_DZU+200E U+002D U+200E 17 az_IRU+200E U+2212 2 fa_IRU+200F U+002D 2 ckb_IQU+2212 23 eu_ESMatched as the literal byte
'-', none of the 77 round-tripped: the display direction emitted a sign the entry direction then rejected, so the pair was not inverse for any of them. Notear_DZ, whose sign is the ordinary hyphen — it failed on the U+200E in front of it, so this was never only "the U+2212 locales", and a widercharwould not have fixed it. Whole-string matching is what covers the 2–3 code point bidi forms, which is why the sign is typed like the separators rather than widened.ASCII
'-'stays accepted whatever the locale. A bare'-'is taken in the leading position in addition tonegativeSign. U+2212 and the bidi marks are on no keyboard, so matching only the locale's own spelling would reject the sign the user can actually type and leave them no way to enter a negative number at all. This is not the kind of guess the grouping rule forbids: that rule is about producing a wrong value, and a hyphen in a numeric entry has no second reading. The canonical output always spells the sign'-', whatever the input spelled it.An empty
negativeSignmeans the ASCII default, not "no sign". Unlike a group separator there is no locale without a negative sign, so empty cannot mean absence — and on the display edge it must not, because a sign that formatted to nothing would turn-5into5: a valid number of the wrong sign, which is silent corruption rather than a rejection.The renderer passes the locale's own sign:
DynamicForm.qmlalready bindsqtLocale: Qt.locale(displayLocale)and forwardsqtLocale.decimalPoint/qtLocale.groupSeparator, and now forwardsqtLocale.negativeSignfrom the same object at all three call sites. The member is defaulted, so a caller that names only the separators is unchanged.displayLocaleis aQLocalename; Qt resolves it, and morph does not.DynamicForm.displayLocale(src/qt/forms/qml/DynamicForm.qml) is a plain string, and every locale fact the two numeric edges receive comes out of theQLocalethatQt.locale(displayLocale)returns — not out of the string. That resolution is Qt's, and it is not an identity: a name with no script subtag resolves to the language/territory's default script, which for a script-split locale need not be the script whose digits the caller wanted. Measured with Qt 6.11.2 by enumeratingQLocale::matchingLocales(AnyLanguage, AnyScript, AnyTerritory)— 711 locales, eleven distinctzeroDigitvalues — and then reconstructing aQLocalefrom each group's own reportedname():mni_IN zeroDigit U+09E6 matchingLocales entry named mni_IN: U+ABF0 ff_BF zeroDigit U+0030 matchingLocales entry named ff_BF: U+1E950 (Adlam) ff_Adlm_BF zeroDigit U+1E950Nine of the eleven representative names round-trip; those two do not.
QLocale("ff_BF")is the Latin-script Fulah and reports ASCII digits, while thematchingLocalesentry whosename()isff_BFis the Adlam-script one. The third row is the remedy and was measured through QML'sQt.locale(...), so the QML path resolves identically.The contract, therefore: the name is the caller's and its resolution is Qt's. A caller that wants a particular script passes the script-qualified form —
ff_Adlm_BF, notff_BF. morph neither validates nor normalisesdisplayLocale, and deliberately does not warn whenQt.locale(displayLocale).name() !== displayLocale: Qt normalises names for many reasons unrelated to scripts, so such a warning would fire on callers with nothing wrong with them, and there is no measured consumer to protect. Measured on this revision, the complete set of names anything in this tree puts throughQt.locale(displayLocale)is{C, de, eu_ES}— the property's default, the two entries of the example'sComboBoxmodel (examples/forms/gui_qml/qml/Main.qml), and one test locale. None is script-split, so nothing here can reach the behaviour above. A rung or example that adopts a script-split name is the trigger to revisit this paragraph.Two consequences worth stating rather than leaving to be re-derived. First, the round trip is safe under a mis-resolution: both edges read their facts from the same
qtLocaleobject, so entry and display agree on whatever Qt resolved, and the failure mode is "quietly the wrong locale", never "the display edge emits text the entry edge rejects". Second,displayLocaleis also the translation catalog key (catalog.lookup(displayLocale, …)), and there it is used unresolved — the raw string. So the one property is read two ways, and a script-qualified name is the spelling the catalog must be keyed on as well.A leading positive sign is accepted on entry and never emitted on display.
normalizeLocaleNumberreadsNumericLocale::positiveSign, matched exactly asnegativeSignis — the locale's own spelling as a whole string, plus a bare ASCII'+'in every locale — and drops what it matches:"+5"normalises to"5", not to"+5". Measured overQLocale::positiveSignfor the same 711 locales under Qt 6.11.2:positiveSignlocales e.g. U+002B 657 CU+061C U+002B 24 ar_EGU+200E U+002B 11 ar_DZU+200E U+002B U+200E 17 az_IRU+200F U+002B 2 ckb_IQ54 of 711 are more than one code point. Unlike the negative side there is no U+2212 analogue, so every non-ASCII spelling here is multi-code-point and whole-string matching is the only thing that can match any of them. Before this, a leading
'+'fell through to the "any other character is malformed" arm and an explicitly-positive entry was rejected in every locale,"C"included.The two functions are deliberately not inverse across a positive sign.
formatCanonicalNumberignoresNumericLocale::positiveSignentirely and never emits a positive sign, in any locale. This breaks the strict inverse relationship the pair otherwise holds, on purpose, and it is written down here rather than left for the next reader to infer from a missing parameter. The reason is the asymmetry in what each direction can get wrong. Canonical text is-?[0-9]+(\.[0-9]+)?— there is no'+'in it — so the entry edge has somewhere to put an accepted'+': nowhere, which costs nothing. The display edge has no such option:positiveSignis'+'in 657 of the 711 locales, so emitting it would turn every positive number in every form from5into+5, a visible change to the product with no reported need behind it. The negative sign is a different case: there the display edge would emit a sign the entry edge rejects, so the pair would be broken and something has to give. Nothing is broken for the positive sign, which is why acceptance stops at the one edge where it is free. Rejecting text the display edge produced is a defect; accepting text no display edge produces is not.Grouping is validated, never merely stripped. A group separator is dropped only where a group separator can legally be: preceded by one to three digits, followed by exactly three more, and never after the decimal separator.
"1.050,25","1.000.000,25"and an ungrouped"1050,25"all normalise;"1.5","1.50","1.05"and"1.2.3.4"in a de-DE locale are malformed, and so is the en-US mirror image"1,5". This is not strictness for its own sake: dropping every occurrence unconditionally, as a naive edge does, turns a de-DE user's US-style"1.5"into15— a perfectly valid number, ten times too large, that no downstream check can recognise as wrong, so the user is charged ten times with no diagnostic anywhere. The field's job at this edge is to report a fact to the layer that owns the policy, not to produce a number at any price.The two separators must differ. A non-empty
groupSeparatorequal todecimalSeparatoris rejected like any other malformed entry — with one string in both roles there is no reading of"1.5"the function could defend, and the old code silently ate the decimal. It is reported through the return value rather than an assertion, deliberately: an assertion would make a control edge behave differently in Debug and Release, and would be untestable in the configuration where it fires.Both edges, or neither.
src/qt/forms/qml/DynamicForm.qmlcarries a JavaScript mirror of this function, and a divergence between them is a divergence in what the product accepts. The mirror produced byte-identical wrong answers on all of the cases above and carries byte-identical validation now; changing one without the other is the defect, not the fix. The mirror compares one UTF-16 code unit at a time, so the whole-string sign match is spelledtext.startsWith(sign, i)there rather thanch === sign— a one-unit comparison could not match the 2–3 code point forms at all. The mirror'snormalizeLocaleNumbertakespositiveSignthe same way and drops it the same way, and itsformatCanonicalNumbertakes none, for the reason above; the renderer forwardsqtLocale.positiveSignat the two entry call sites that already forwardqtLocale.negativeSign, and nothing changes at the display call site. All three call sites now also forwardqtLocale.zeroDigit, the display one included — that is what "both edges, or neither" costs for the digit base.The two separators are matched the same way, for consistency rather than for a locale. Spelling the mirror's separator branches as
ch === groupSeparatorandch === decimalSeparatorwould be a one-code-unit comparison sitting a few lines from the whole-string sign match, with nothing saying why. They aretext.startsWith(sep, i)as well, advancing the index by the separator's length the way the sign branches already do.Unlike the signs, no locale reaches this, and the rule rather than a user report is the reason to fix it. Measured with
QLocale::matchingLocalesunder Qt 6.11.2, over the same 711 locales:field spellings longer than one UTF-16 code unit decimalPoint0 of 711 groupSeparator0 of 711 negativeSign(control)54 of 711 positiveSign(control)54 of 711 The two sign rows are the control: this is not a measurement that returns zero for any locale field it is pointed at. Every one of the nine distinct
groupSeparatorspellings (U+0027, U+002C, U+002E, U+00A0, U+060C, U+066C, U+12C8, U+202F, U+2E41) and all threedecimalPointspellings (U+002C, U+002E, U+066B) is a single code unit, so entry behaviour is byte-identical before and after for every locale Qt knows. The change is to the rule this paragraph states, not to the product.That has a consequence for how it can be tested, and it is the reason this is written down: a test driven by a real
Qt.locale(...)cannot distinguish the fixed mirror from the broken one. With a one-unit separator,ch === sepandstartsWith(sep, i)agree on all 711, so such a test passes whatever the code does. The corpus that pins this is therefore synthetic — separators of two or more code units that no locale uses, passed straight to both functions — and it is pinned identically on both edges ([morph599]intests/test_render_locale_format.cpp, and thetest_aMultiUnitSeparator*functions insrc/qt/forms/tests/tst_i18n.qml). One of the synthetic separators is a surrogate pair: a single code point that is two code units, which a per-code-point mirror would still get wrong. -
Timestamps. The wire value is strict UTC ISO-8601 (datetime.md); a renderer displays and edits in the user's zone by shifting a
morph::time::DateTimewith its existing duration-arithmetic operators (dt + std::chrono::minutes{offset}for display,dt - std::chrono::minutes{offset}back to canonical UTC before submission) — no new arithmetic is needed, only the offset the renderer chooses to display in. A locale-formatted entry must round-trip to the identical canonical wire value. -
Choice option labels are data, not chrome. Option rows come from executing the options action (choice.md); the catalog never sees them. A model that wants localised rows reads
session::current()->localeserver-side (session.md) — the one place server-side locale participates.
- No per-locale schema variants —
schemaJson<A>()keeps its one cached, un-localised schema. - No translation storage format — the
TranslationProvidersignature is the whole contract. - No server-side message localisation — canonical error strings stay diagnostic/protocol vocabulary.
- No RTL / layout mirroring engine.
- Not machine translation, locale negotiation, or plural rules — the catalog is a lookup; anything richer lives inside the host's provider implementation.
template <typename A>
[[nodiscard]] constexpr bool allRequiredEngaged(const A& action) noexcept;Returns true when every required empty-capable member of action has
hasValue() == true. Required has the same meaning as in the
Required-ness rule: not std::optional<...>, not
listed in A::optionalFields, and not a computedFields destination.
Non-empty-capable members (plain ints, strings, etc.) are skipped — they
cannot express "not filled in". Intended as the body of the action's
validate() (the ActionValidator machinery picks it up automatically).
The two exclusions are enforced by different mechanisms, and only one is an
explicit test. A member is inspected at all only when it satisfies
EmptyCapableField (.hasValue() exists); the sole explicit check inside the
loop is !declaredOptional<A>(name) against A::optionalFields. A
std::optional<...> member is not excluded by an isStdOptional test here —
it is skipped because std::optional exposes has_value(), not hasValue(),
so it never satisfies EmptyCapableField in the first place. (This differs from
the required-array derivation in mergeSchemaExtras, which checks
isStdOptional explicitly — see Required-ness rule.)
The predicate is noexcept and constexpr, and it inspects only the action's
own top-level members; unlike schemaJson<A>()'s schema generation (see
Nested aggregates (recursive, cycle-safe)),
it does not recurse into a nested aggregate member's own fields.
allRequiredEngaged is per-field and membership-blind by design. A condition
spanning two or more fields — "end date must be after start date", "supply
either an email or a phone but not both", "discount is required only when a
promo code is entered" — is expressed with a closed, typed rule vocabulary
declared once as an action's static constexpr formRules member, built with
morph::forms::ruleList(...):
struct BookRoom {
morph::time::Timestamp checkIn;
morph::time::Timestamp checkOut;
std::optional<std::string> email;
std::optional<std::string> phone;
Quantity<Unit::money> promo;
Quantity<Unit::money> discount;
static constexpr auto formRules = morph::forms::ruleList(
morph::forms::greater(&BookRoom::checkOut, &BookRoom::checkIn),
morph::forms::exactlyOneOf(&BookRoom::email, &BookRoom::phone),
morph::forms::requiredWhen(&BookRoom::discount, morph::forms::engaged(&BookRoom::promo)),
morph::forms::visibleWhen(&BookRoom::discount, morph::forms::engaged(&BookRoom::promo)));
[[nodiscard]] bool validate() const {
return morph::forms::allRulesSatisfied(*this) && morph::forms::allRequiredEngaged(*this);
}
};One declaration drives three consumers: schemaJson<A>() emits it as a
top-level x-rules array (alongside required); allRulesSatisfied<A>(action)
evaluates it as the shared C++ predicate; and because validate() calls
allRulesSatisfied, ActionValidator<A>::ready (registry.md)
picks it up automatically on every dispatch path that already enforces
ready() — morph::flows::FlowSession::set<>'s gate, the client
request/reply gate, and the server dispatch runner
(registry.md) — with no extra
code anywhere. The vocabulary is deliberately closed: adding a new rule kind is
a framework change, never an application-supplied lambda, which is what lets
the client and the server evaluate identically from the same serialized form.
Every row names all three spellings of the same kind: the factory a C++ author
calls, the kind string the schema carries, and the RuleKind enumerator the
framework switches on. They are listed together because a reader emitting JSON
and a reader writing C++ read the same table, and the C++ capitalisation is not
derivable from the wire spelling by any rule stated anywhere.
a gate removed on 2026-09-23 (check 6) reads ruleKindName()'s switch and
requires each enumerator and its wire spelling to appear in one row here, so a
kind added to the enum cannot reach the wire undocumented.
| Factory | Meaning | x-rules kind |
RuleKind enumerator |
Also valid as a condition? |
|---|---|---|---|---|
requiredWhen(field, cond) |
field must be engaged when cond holds. |
"requiredWhen" |
RuleKind::RequiredWhen |
no (only ranges over conditions itself) |
greater(a, b) / greaterOrEqual(a, b) |
*a > *b / *a >= *b. |
"greater" / "greaterOrEqual" |
RuleKind::Greater / RuleKind::GreaterOrEqual |
yes |
less(a, b) / lessOrEqual(a, b) |
*a < *b / *a <= *b. |
"less" / "lessOrEqual" |
RuleKind::Less / RuleKind::LessOrEqual |
yes |
exactlyOneOf(f1, f2, ...) |
Exactly one listed field is engaged. | "exactlyOneOf" |
RuleKind::ExactlyOneOf |
no |
atLeastOneOf(f1, f2, ...) |
At least one listed field is engaged. | "atLeastOneOf" |
RuleKind::AtLeastOneOf |
no |
mutuallyExclusive(f1, f2, ...) |
At most one listed field is engaged. | "mutuallyExclusive" |
RuleKind::MutuallyExclusive |
no |
visibleWhen(field, cond) |
Presentation: field is shown only while cond holds. |
"visibleWhen" |
RuleKind::VisibleWhen |
no |
readonlyWhen(field, cond) |
Presentation: field is editable only while cond does not hold. |
"readonlyWhen" |
RuleKind::ReadonlyWhen |
no |
engaged(field) / notEngaged(field) |
field is / is not engaged. |
"engaged" / "notEngaged" |
RuleKind::Engaged / RuleKind::NotEngaged |
yes (condition-only) |
equals(field, literal) |
field's engaged value equals literal. |
"equals" |
RuleKind::Equals |
yes (condition-only) |
andOf(cond1, cond2, ...) |
Every listed condition holds (boolean AND). | "and" |
RuleKind::And |
yes — also usable directly as a top-level rule |
orOf(cond1, cond2, ...) |
At least one listed condition holds (boolean OR). | "or" |
RuleKind::Or |
yes — also usable directly as a top-level rule |
notOf(cond) |
The nested condition does not hold (boolean NOT). | "not" |
RuleKind::Not |
yes — also usable directly as a top-level rule |
engaged/notEngaged/requiredWhen/the membership rules accept any
EngageableField — an EmptyCapableField (Quantity/Choice/Timestamp) or
a plain std::optional<T> (which does not satisfy EmptyCapableField —
see "two exclusions" above — but does count as engageable for rule purposes).
greater/greaterOrEqual/less/lessOrEqual are narrower: both operands
must be the same EmptyCapableField type whose engaged value
(operator*()) is three-way-comparable — Quantity<U, Dec> (compares the
exact math::Rational payload, never a double) or morph::time::Timestamp
(compares DateTime). An unengaged operand makes a comparison vacuously
satisfied (true) — both as a top-level rule and when reused as a nested
condition — so a form still being filled in never fails a comparison
prematurely; the required-ness of the operand itself is a separate
required/requiredWhen concern. equals, by contrast, is not vacuous:
an unengaged field cannot equal anything, so it returns false until the
field is engaged. A literal passed to equals is one of std::int64_t,
bool, std::string, the exact math::Rational (never a double), or a
captured string literal, so it serialises losslessly into x-rules.
Comparing a field to a literal is not this vocabulary's job. equals is
the only node that takes one, and only for equality — there is deliberately no
greaterOrEqual(&A::field, 1). A bound on a single field's value is a property
of that field, not a relation between two of them, and is declared on its
FieldMeta instead: see
Per-field scalar bounds,
which also covers integrality (multipleOf), a constraint x-rules cannot
express in any form.
A bare string literal — equals(&A::code, "URGENT") — is captured inline
as a detail::LiteralString (an alias for the project's shared
morph::detail::FixedString), not copied into a std::string. That is what
keeps the documented
static constexpr auto formRules = ruleList(...) declaration working for a
literal of any length: a rule node has to be a literal type, and a std::string
holding more characters than the standard library's small-string buffer (15 on
libstdc++) allocates, so the declaration fails with "refers to a result of
operator new". The limit was invisible in the source — the same code compiled
or did not depending only on how long the literal was, and on which standard
library was in use. Serialisation is unaffected: emitNode() emits the same
JSON string either way. Passing an explicit std::string still stores a
std::string and still cannot be constexpr when it allocates; that is
inherent to the type the caller chose.
The single-node conditions above (engaged, notEngaged, equals, and the
comparison kinds reused as booleans) compose into a recursive condition
tree via three more factories:
struct BookRoom {
// ...
static constexpr auto formRules = morph::forms::ruleList(
// discount required only when BOTH promo and a loyalty code are engaged
morph::forms::requiredWhen(
&BookRoom::discount,
morph::forms::andOf(morph::forms::engaged(&BookRoom::promo),
morph::forms::engaged(&BookRoom::loyaltyCode))));
};andOf(cond1, cond2, ...)— holds when every listed condition holds (at least two conditions;test()short-circuits left to right).orOf(cond1, cond2, ...)— holds when at least one listed condition holds (at least two conditions;test()short-circuits left to right).notOf(cond)— holds when the single nested condition does not hold.
Each factory accepts any node satisfying the morph::forms::Condition concept
as a child — a leaf (engaged, equals, greater, …), a membership rule, or
another andOf/orOf/notOf — so a tree nests to any depth:
orOf(notOf(engaged(&A::x)), andOf(engaged(&A::y), engaged(&A::z))) is a valid
when clause. All three nodes share this uniform shape with every existing
rule/condition node (kind, test(const A&) const noexcept, emitNode()),
which is what makes them substitutable everywhere an existing single-node
condition already worked:
- Nested inside a
whenclause —requiredWhen/visibleWhen/readonlyWhenaccept a compound condition in the samewhenposition a leaf condition occupies, with no change to those three rule kinds themselves. - Directly as a top-level
formRulesentry —andOf/orOf/notOfdeclareisPresentation = falseand atest(), soruleList(andOf(...))is itself a valid, directly-gating rule — "a single rule with a compound condition tree", not only a condition factored inside another rule.
andOf/orOf/notOf add no new closed-vocabulary rule kinds — they are
closed-vocabulary conditions, matching the existing "closed, typed" design
of every other node in this table: an application still cannot supply an
arbitrary lambda, only compose the existing typed primitives into a tree.
The three combinators and the three when-bearing rules constrain their
condition operands on morph::forms::Condition, which a node opts into with
static constexpr bool isCondition = true. visibleWhen, readonlyWhen and
requiredWhen do not opt in, and the reason is worth stating because they
have the same shape as everything that does.
VisibleWhen::test() and ReadonlyWhen::test() return true
unconditionally, by design: they are presentation rules, they never gate
submission, and a renderer reads their when clause rather than calling them
(see the rule list). Nested as a condition,
such a node therefore contributes a constant — andOf(visibleWhen(…), c)
collapses to c — while looking exactly like a condition that says something.
The author wrote "while this field is visible" and got "true". requiredWhen
is excluded for the neighbouring reason: it is a rule about a condition
rather than a condition, and nesting one emits a "requiredWhen" node in a
when position no renderer's condition vocabulary has a case for. All six
spellings compiled before this was enforced, because "exposes
test(const A&) const noexcept" is a test every rule node passes.
The membership rules (exactlyOneOf / atLeastOneOf / mutuallyExclusive)
do opt in: their test() genuinely ranges over the action, so composing
one into a tree changes what the tree evaluates to. A renderer that does not
recognise them in a when position treats them as "cannot evaluate" and defers
to the server, which is the sanctioned fallback (see
Renderer fallback) rather than a disagreement between the
two evaluators. The "Also valid as a condition?" column above records which
kinds the shipped renderer's condition vocabulary evaluates directly; it is
not the same question as which nodes the C++ factories accept.
The marker is declared per node rather than derived from isPresentation
(which would wrongly admit RequiredWhen) or from the presence of test()
(which admits everything), so adding a kind to the condition vocabulary is one
line on the node itself.
The same constraints carry two diagnostics. andOf/orOf recover the action
type from their first operand, and now require every other operand to agree,
so andOf(engaged(&C::x), engaged(&B::y)) is an error at the call site rather
than 153 lines whose first line is inside <type_traits>. And equals requires
the field to be comparable against the literal at all, so
equals(&A::someQuantity, "URGENT") names equals and the caller's own line
instead of reporting no match for 'operator==' from inside forms.hpp.
andOf/orOf emit a "conditions" array of nested condition nodes;
notOf emits a single nested "condition" object (singular, since it wraps
exactly one child):
{ "kind": "requiredWhen", "fields": ["discount"],
"when": { "kind": "and", "conditions": [
{ "kind": "engaged", "fields": ["promo"] },
{ "kind": "engaged", "fields": ["loyaltyCode"] }
]}
}{ "kind": "or", "conditions": [
{ "kind": "not", "condition": { "kind": "engaged", "fields": ["promo"] } },
{ "kind": "and", "conditions": [
{ "kind": "engaged", "fields": ["email"] },
{ "kind": "engaged", "fields": ["phone"] }
]}
]}A renderer that does not recognise "and"/"or"/"not" treats them as an
unrecognised kind (see "Renderer fallback" below) — it defers enforcement to
the server rather than guessing at the nested structure, exactly like any
other unrecognised kind. A renderer that recognises them but not one of
their children has the same answer available: "cannot evaluate" propagates
up through and/or/not rather than collapsing to false.
visibleWhen/readonlyWhen are the only two presentation kinds: they
never participate in allRulesSatisfied (skipped by construction, via each
node's isPresentation flag), only in what a renderer shows/enables. While a
field is hidden by visibleWhen, its current draft value still travels in the
payload — hiding never clears it, exactly like a static x-hidden field. An
author who wants "hidden ⇒ also not required" pairs visibleWhen(f, c) with
requiredWhen(f, c) explicitly; neither implies the other.
mergeSchemaExtras walks A::formRules (when declared) and emits a
top-level x-rules array, alongside required. Each element is
self-describing JSON a renderer (or the server) can evaluate without any C++
type information:
"x-rules": [
{ "kind": "greater", "fields": ["checkOut", "checkIn"] },
{ "kind": "exactlyOneOf", "fields": ["email", "phone"] },
{ "kind": "requiredWhen", "fields": ["discount"],
"when": { "kind": "engaged", "fields": ["promo"] } },
{ "kind": "visibleWhen", "fields": ["discount"],
"when": { "kind": "engaged", "fields": ["promo"] } }
]Field names are the wire (JSON) field names, resolved from the
pointer-to-member the same way x-order is derived: a fresh probe instance of
the action is walked and each rule's stored member pointer is matched against
the probe's members by address. An action with no formRules emits no
x-rules key at all — byte-identical to a version of the schema generated
before this feature existed.
required is derived from field required-ness (Required-ness rule);
x-rules is derived from A::formRules. Nothing links the two derivations, so
an action can declare both halves sensibly on their own and still describe a
form no submission can satisfy:
struct CaptureConcentration {
Concentration value; // EmptyCapableField -> required by default
QualifierChoice qualifier; // EmptyCapableField -> required by default
// `required` demands both. `exactlyOneOf` permits exactly one.
static constexpr auto formRules = morph::forms::ruleList(
morph::forms::exactlyOneOf(&CaptureConcentration::value,
&CaptureConcentration::qualifier));
};A renderer honouring required demands both fields; a payload meeting that
demand then fails exactlyOneOf on the server. The form is dead on arrival,
and both halves of the served schema look entirely reasonable in isolation.
schemaJson<A>() rejects this at generation by throwing
morph::forms::UnsatisfiableFormError. The check lives in
detail::rejectUnsatisfiableRules, called from mergeSchemaExtras — the one
place both halves are in hand — and reads the emitted rule nodes against the
emitted required array, so it matches on the same wire names a renderer
would.
What counts as a contradiction. A rule kind that caps how many of the
fields it ranges over may be engaged at once, ranging over two or more
fields that are also in required:
| Rule kind | Caps engagement? | Why |
|---|---|---|
exactlyOneOf |
yes — ceiling of one | Two required fields cannot both be engaged and still be "exactly one". |
mutuallyExclusive |
yes — ceiling of one | Same ceiling; "at most one" and "both required" cannot hold together. |
atLeastOneOf |
no — it is a floor | Satisfied by engaging every field it names, so it can never contradict required. Rejecting it would be a false positive. |
requiredWhen |
no | Only ever adds required-ness; it cannot cap anything. |
| everything else | no | Comparison, presentation, and compound kinds impose no engagement ceiling. |
detail::capsEngagedCount(kind) is the single place the capping kinds are
named. A future rule kind carrying a ceiling ("at most two of these") joins
that list and is covered with no other change.
The check reads a list of rule nodes as a conjunction: every element has to
hold, so a contradiction in any one element is a contradiction of the whole.
The top-level x-rules array is such a conjunction — allRulesSatisfied folds
it with && — and so is an and node's conditions. The check therefore
descends into and, at any depth:
// Both of these are rejected. They are the same contradiction.
ruleList(exactlyOneOf(&A::a, &A::b))
ruleList(andOf(exactlyOneOf(&A::a, &A::b), engaged(&A::c)))The second spelling used to ship silently, because the check skipped any node
with no fields key and and/or/not emit conditions/condition
instead. The same contradiction being a hard build failure in one spelling and
an unsubmittable form in the other is worse than not checking at all: the
check's existence is what an author trusts.
or and not are not descended, and that is a property of the operators
rather than an omission:
- Under
or, a contradictory operand only makes that branch dead. The other branch can still satisfy the rule, soruleList(orOf(exactlyOneOf(&A::a, &A::b), engaged(&A::c)))generates. - Under
not, the contradiction inverts into a requirement. Withaandbbothrequired,ruleList(notOf(exactlyOneOf(&A::a, &A::b)))asks for not exactly one of them engaged — which engaging both, precisely whatrequiredalready demands, satisfies. So it generates too.
Rejecting either would be a false positive, and a false positive here is a hard build failure on a form that works.
Boundaries that deliberately do not throw:
- Exactly one required field inside a capping rule. Satisfiable: engage that field, leave the rest empty. Only two or more conflict.
std::optionalmembers.detail::isStdOptionalkeeps them out ofrequiredon sight, so a rule overstd::optionalfields can never reach the contradiction — with or without this check. The reachable case is anEmptyCapableField(aQuantity, aChoice, a strong id): required by default, and rangeable by a membership rule.- Required fields the rule does not name. Only the intersection of the
rule's
fieldsandrequiredis counted.
How an author fixes it. Name the rule's fields in A::optionalFields. The
rule then becomes the only gate on them, which is what the multi-field
sum-type encoding (Sum types not in the forms palette)
actually means. Alternatively, drop the rule.
Why this one exception to permissive generation. Everywhere else, schema
generation tolerates an author's declaration mistake silently: a formLayout
entry naming a field the action does not have is ignored, a field claimed by
two groups keeps the first. That is right, because a tolerated mistake still
yields a working form — the author loses a layout hint, not the form. This
case is different in kind: the result is a form nobody can submit, on any
client, with no error naming the reason. The failure is already certain at
generation time and belongs to the author's own build, so it is raised there
rather than left to surface as a user who cannot press Save. A static_assert
would be better still, but detail::resolveFieldName is not constexpr (it
matches member addresses against a runtime probe instance), so a
generation-time throw is the achievable form today.
Interaction with the schema cache. schemaJson<A>() memoises into a
function-local static const std::string. A throw during that static's
initialisation leaves it uninitialised, so a later call re-runs the check and
throws again, rather than serving a half-built or empty schema.
The server never trusts the client's evaluation of x-rules; it re-runs
A::formRules itself. Because an action's validate() calls
allRulesSatisfied(*this), and ActionValidator<A>::ready auto-detects
validate() via HasValidate (registry.md), the
server dispatch runner evaluates the exact same rule list the client did —
the same typed nodes over the same values — with zero extra server code. A
hand-built envelope that violates a rule is rejected with
morph::model::ValidationError (registry.md) on every
dispatch path (local, simulated-remote, Qt WebSocket), before Model::execute
runs.
Read "the same rule list", not "the same evaluation". The server walks
A::formRules — typed nodes over decoded members. A client walks the emitted
x-rules JSON over widget state, which is a different representation of the
same declaration: a Quantity is exact Rational arithmetic on one side and
entered text on the other, and a client is free to be an approximation of the
server, never the reverse. Two consequences follow, and both are load-bearing
rather than caveats:
- a client verdict is a convenience, and the correctness floor is the
server's — no client rounding, no unrecognised kind, and no missing key can
let an action past
validate(); - because the two are different code, "they agree" is a property that has to be tested, not assumed. See Two evaluators, one corpus.
Every key here is additive and optional, consistent with the unversioned
schema stance below. An action declaring no formRules emits no x-rules and
behaves exactly as before this feature existed. A renderer that does not
understand x-rules still produces a usable form: it honours the per-field
required array and lets the server reject any cross-field violation —
the correctness floor never depends on the client understanding the key. An
unrecognised kind (a rule or a nested condition) must be treated as
"cannot evaluate" by a client renderer, which defers enforcement to the
server rather than passing the rule — the server, running the compiled C++
rule list directly, has no such "unrecognised kind" case.
"Cannot evaluate" is a third answer, alongside true and false, and the two
shipped clients of this sentence can read it in opposite directions — block
submission on an unknown kind, or defer. The contract is defer:
| Question a renderer asks | Answer when the condition cannot be evaluated |
|---|---|
| Does this rule block submission? | No. Hand the payload to the server. |
Is this requiredWhen field required right now? |
No. Only a definitely-true condition makes a field required. |
Is this visibleWhen field shown? |
Yes. Never hide a field over a condition you could not judge. |
Is this readonlyWhen field frozen? |
No. Leave it editable. |
What is and/or/not of it? |
"Cannot evaluate" propagates: and is false if any child is false, unevaluable if none is false but some is unevaluable, true otherwise; or is true if any child is true, unevaluable if none is true but some is unevaluable, false otherwise; not of unevaluable is unevaluable. |
The reason is forward compatibility, and it is the whole point of a vocabulary that is closed but extensible. Every key here is additive: a server that gains a seventeenth rule kind must not thereby brick every renderer already deployed. A blocking client turns each such addition into a breaking change — the operator sees a form that can never be satisfied, with no error naming why, and no action of theirs can fix it. A deferring client submits, and the server answers with the one verdict that was ever authoritative.
Nothing is lost on the safety side, because nothing was ever gained there: the
correctness floor is the server's validate(), which evaluates the compiled
rule list and cannot fail to recognise a kind. "Fail closed" is the right
instinct for a decision, but a client gate is not a decision — it is a
prediction of one, and a prediction that refuses to be made must not be
allowed to veto the decision.
The last row above is why the three-valued reading matters even for a renderer
that understands every top-level kind it is sent. Collapsing "cannot evaluate"
into false makes not of an unknown child come out true, so a
requiredWhen keyed on it starts demanding a field for a reason the renderer
has just admitted it cannot judge — blocking through the back door.
x-rules is evaluated twice in this repository, and a reader should know that
before trusting either:
| Evaluator | Where | Over what |
|---|---|---|
| Compiled | morph::forms::allRulesSatisfied (forms/forms.hpp) |
A::formRules, typed nodes over decoded members |
| Client | testRule/testCondition in src/qt/forms/qml/DynamicForm.qml |
the emitted x-rules JSON over widget text |
They are different code over different representations, so agreement is a
property to be measured. It is measured by a single shared artifact —
src/qt/forms/tests/data/rule_corpus.json, one file with two readers:
tests/test_forms_rule_corpus.cppdrives every row throughallRulesSatisfied;src/qt/forms/tests/tst_DynamicFormRuleCorpus.qmldrives the same rows through a realDynamicForm.
Each row is (schema, field state, expected verdict). The schemas are stored
as text and parsed by the renderer exactly as an application parses
controller.schemasJson, because parsing is what rounds an integer literal
past 2^53 — a fixture built as an inline JSON object could not express that
case at all.
Three assertions are what make this a pin rather than a pair of samples, and a
change to x-rules is expected to keep all three true:
- every corpus schema equals the current
schemaJson<A>()byte for byte; - every
kinddetail::ruleKindNamenames appears somewhere in the corpus, so a seventeenth rule kind cannot join the vocabulary without rows; - every kind carries both verdicts — a corpus whose rows all said "allow" would pass against a client that never blocks anything.
visibleWhen/readonlyWhen never gate submission, so their rows carry a
presentation expectation instead of a submit verdict; only the renderer can
assert it, since a VisibleWhen node's test() returns true unconditionally
by construction.
Some fields are not entered by the user at all — they are a pure function
of other fields on the same action: total = qty * price, vatDue = net * rate. An action declares one with a static constexpr map from a
destination member to its declared input members and a pure derivation,
next to optionalFields/formRules:
struct LineItem {
Quantity<Units, 2> qty;
Quantity<Units, 2> price;
Quantity<Units, 2> total; // computed -- not user-entered
// A generic (auto) lambda parameter, not `const LineItem&`: this
// initializer runs while LineItem is still an incomplete type (a static
// data member initializer is not a complete-class context the way a
// member function body or a non-static default member initializer is
// -- see "Incomplete-type self-reference" above), so the body's member
// access must stay dependent until first use, after the class is complete.
static constexpr auto computedFields = morph::forms::computeList(
morph::forms::computed<&LineItem::total, &LineItem::qty, &LineItem::price>(
[](const auto& s) { return s.qty * s.price; }));
[[nodiscard]] bool validate() const { return morph::forms::allRequiredEngaged(*this); }
};computed<Dst, Inputs...>(fn)binds a destination member, its ordered input members, and a pure derivationfn(const A&) -> ValueOfDst.DstandInputs...are pointer-to-data-member NTTPs (trailing template arguments, not a braced-list runtime parameter), so a renamed or deleted field is a compile error and the input list is type-checked.computeList(...)composes one or morecomputed(...)declarations into adetail::ComputeList<...>value assigned tostatic constexpr auto computedFields. The framework detects it via thedetail::HasComputedFields<A>concept, mirroringdetail::HasOptionalFields<A>/HasFormRules<A>.recomputeAll<A>(action)is the single evaluator: it walksA::computedFieldsand, for each entry, overwrites the destination member withfn(action)— or, if any declared input is unengaged (hasValue() == false, for an input satisfyingEmptyCapableField; a non-empty-capable input is always considered engaged), resets the destination to its default-constructed (empty) value instead of computing from a missing operand. For aQuantitydestination the result is converted to the destination's own type and rounded to its declared precision (Quantity::atDeclaredPrecision()), so the stored value matchesx-decimalPlacesregardless of what declared precisionfn's return type happened to carry, and regardless of how many decimals the derivation itself produced — a product of two 2-decimal operands is exact to 4.fnmust be pure — a function of the action's own fields only, no side effects, no external state. The framework cannot check this; it is the author's contract. Anything impure (model state, a database lookup, the current time) belongs in the model'sexecute, not a computed field.
mergeSchemaExtras patches each computed destination's property node with
x-readonly: true and x-computed: { "inputs": [...] } (wire field names, in
declaration order, resolved from the pointer-to-member the same way
x-order is derived), and excludes it from the synthesised required
array (see Required-ness rule) — a computed field is
never something the user must fill. x-computed/x-readonly are additive,
optional x-* keys (see the renderer contract
table below); an action that declares no computedFields emits neither key.
recomputeAll runs at three call sites, all authoritative:
ActionExecuteRegistry::registerAction's executor (the client-bridge JSON dispatch path behindBridgeHandler::executeJson, bridge.md).Bridge::executeVia'slocalOp(the in-process execution pathLocalBackenduses for everyexecute<Action>()/executeJsoncall, bridge.md).ActionDispatcher::registerAction's runner (the server-side execution pathRemoteServeruses forSimulatedRemoteBackendand the Qt WebSocket transport, registry.md).
Sites 2–4 run after decode and before Model::execute, so a computed
value arriving on the wire is always discarded and replaced with the
authoritative recomputation — a hostile or buggy client cannot influence the
stored value by tampering with a computed field. On every site that also
decodes JSON (2 and 4; localOp never does — it dispatches an already-typed
Action), recomputeAll runs immediately after reconcileDeclaredPrecision
and before the ActionValidator::ready check, so a validator that
inspects a computed field sees the authoritative, server-derived value rather
than whatever arrived on the wire. Because every site calls the identical
recomputeAll over inputs reconciled to declared precision
(reconcileDeclaredPrecision, above),
the client's displayed value and the server's stored value are identical to
the last digit. It is a no-op for actions with no computedFields — zero
behaviour change, backward compatible — mirroring how reconcileDeclaredPrecision
no-ops for actions with no Quantity members.
A cross-field rule (Cross-field rules)
that references a computed field evaluates on the server's authoritative
recomputed value, not the client's, since recomputeAll runs before the
validator check on every dispatch path.
Everything above derives a schema from the compiled action type. When a form
definition is itself data — a versioned analysis catalogue, a per-tenant
configuration — the values of some framework-meaningful keys belong to a
database row rather than to a template parameter, and no amount of reflection
over A can reach them.
morph::forms::InstanceConstraints (forms/instance_constraints.hpp) is the
seam for exactly that, and no more than that: an instance varies the values
of existing keys; it never varies the form's shape. One declaration both
decorates the served schema (x-decimalPlaces, x-minimum, x-maximum, plus
the document-level x-instanceConstraints stamp) and checks a submitted value
against the same numbers, so the two cannot drift apart — which is what an
application patching a private key beside the framework's could never promise.
The framework reports violations and the model applies policy; the dispatch runners do not apply instance constraints, because they have no instance to read one from. See instance_constraints.md for the API, the emitted keys, and the reasoning behind both of those decisions.
The shipped renderer honours the decorated values. That is the half that
makes decoration worth doing rather than a second opinion nobody reads:
DynamicForm.qml takes x-decimalPlaces as the entry granularity whether it
came from the compiled type or from a row, and refuses a Quantity outside
x-minimum/x-maximum in the canonical unit, alongside the compiled
minimum/maximum — an instance range narrows the type's, it never widens it.
Two boundaries follow the framework's own:
Quantityfields only, matchingcheckAction. A client that gated a key the model does not check would be a new divergence, not a repair of one.- The client comparison is a
doublequotient of the bound's{num,den}, exactly like the compiledminimum/maximumbeside it. The exact comparison ischeckValue's, against aRational; as everywhere else in the renderer, the live gate is an approximation and the model is the floor.
| Symbol | Kind | Purpose |
|---|---|---|
detail::IsStdOptional<T> |
trait | true when T is a std::optional<...>. |
detail::isStdOptional<T> |
variable template | cvref-stripped alias of the trait. |
detail::HasOptionalFields<A> |
concept | true when A has a static constexpr iterable optionalFields. |
detail::declaredOptional<A>(name) |
constexpr function | true when name appears in A::optionalFields. |
detail::forEachNamedMember(action, visitor) |
function template | Calls visitor.operator()<I>(name, member) for every reflected member of action (uses glaze pure reflection). |
detail::findMember(node, key) |
function template | The checked read over a glz::generic_u64 object node: a pointer to the member, or nullptr when node is not an object or has no such key. Never inserts, never throws; const-preserving. See Reading the DOM with findMember. |
detail::mergeSchemaExtras<A>(raw) |
function | Post-processes a glaze-generated schema to inject required, x-decimalPlaces, x-order, x-unitAlternatives, x-optionsAction, title, description/x-placeholder/x-readonly/x-hidden etc. onto the property nodes. Called by schemaJson<A>(). |
reconcileDeclaredPrecision<A>(action) |
function | Rounds every Quantity member of action in place to its declared precision (atDeclaredPrecision(), an exact Rational re-rounding — not a retag), so a decoded wire value equals the schema's advertised x-decimalPlaces, not merely displays at it. Empty members stay empty. No-op for non-Quantity members and for action types glaze cannot reflect. Called on both wire dispatch paths (bridge.hpp, registry.hpp); not on the in-process localOp path, which decodes no JSON. |
FieldMeta |
struct | Per-field descriptor: field, label, help, placeholder, widget (control-selection override, see Widget hints), readOnly, hidden, i18nKey, plus the scalar bounds minimum/maximum/multipleOf, and the withPlaceholder/withReadOnly/withHidden/withMinimum/withMaximum/withMultipleOf fluent copies. See "Field metadata" above. |
detail::HasFieldMetadata<A> |
concept | true when A has a static constexpr/static const iterable fieldMetadata. |
detail::findFieldMeta<A>(name) |
function | Returns the FieldMeta entry naming name, or nullptr. |
detail::BoundCheckableInteger<T> |
concept | true for an integral T (never bool) every value of which is exactly representable as a Rational numerator — every signed type, plus every unsigned type narrower than 64 bits. Decides which integral members allFieldBoundsSatisfied checks. |
detail::satisfiesDeclaredBounds(meta, value) |
constexpr function | true when an exact Rational is within meta's declared minimum/maximum and an exact multiple of its multipleOf. The single implementation of the bound semantics. |
detail::annotateDeclaredBounds(property, meta) |
function | Stamps whichever of minimum/maximum/multipleOf meta declares onto one property node. Called from annotateBasicMemberProperty, so nested aggregates get the same treatment. |
detail::inferTitle(name) |
function | Title-cases a wire key on camelCase/underscore boundaries. |
describe<MemberPtr>(label, help) |
function template | Builds a FieldMeta whose field is resolved from the pointer-to-member MemberPtr at runtime. Not constexpr — see "Field metadata" above for why, and for the out-of-line declaration a describe<>()-based fieldMetadata array needs. |
| Signature | Returns |
|---|---|
template <typename A> const std::string& schemaJson() |
The merged schema JSON. Cached per type and returned by reference (the same shape model::payloadFingerprint<A>() and model::payloadShapeString<A>() use): the cache is built once per type per process, is never mutated afterwards, and lives until the process exits, so the reference stays valid for as long as any caller could hold it. A caller that needs its own mutable copy asks for one (std::string mine = schemaJson<A>();); an explicit specialisation of this template must likewise return a reference to something that outlives the call, not to a temporary. On internal failure returns the raw glaze schema, or an empty string if glaze's own schema generation failed — it never throws over malformed input. Throws UnsatisfiableFormError for a self-contradicting declaration (Unsatisfiable declarations). |
| Signature | Returns |
|---|---|
template <typename A> bool allRequiredEngaged(A const&) |
true when every required empty-capable field is engaged. |
| Signature | Returns |
|---|---|
template <typename A> bool allFieldBoundsSatisfied(A const&) |
true when no A::fieldMetadata bound (minimum/maximum/multipleOf) is violated. Trivially true for an action declaring no fieldMetadata. An unengaged empty-capable field is vacuously satisfied. noexcept. See Per-field scalar bounds. |
| Signature | Checks |
|---|---|
template <typename T> concept EmptyCapableField |
const T& has a noexcept .hasValue() returning convertible-to-bool. |
Both types are owned by choice.hpp and specified in full in
choice.md — this spec does not restate their member-by-member API,
to avoid two copies drifting apart. In brief: Choice<T, "Action", "value", "label"> is an optionally-empty value (std::optional<T> payload, hasValue(),
unchecked operator*, defaulted operator==) whose options come from executing
a named registered action; optionsAction()/valueField()/labelField()
expose the compile-time metadata that mergeSchemaExtras reads to emit
x-optionsAction/x-optionValue/x-optionLabel. An optional trailing
DependsOn pack names sibling fields whose current values parameterise the
options action (a cascading picklist); optionsDependsOn() exposes it, and
mergeSchemaExtras emits x-optionsDependsOn only when it is non-empty — an
independent Choice (the default) is unaffected. FixedString<N> is the
consteval NTTP string that carries those names inside the Choice type. The
isChoice<T> trait (true for any cvref-stripped Choice) is what
mergeSchemaExtras and allRequiredEngaged branch on. See choice.md
for the exhaustive tables and design rationale.
| Symbol | Kind | Purpose |
|---|---|---|
EngageableField<T> |
concept | EmptyCapableField<T> or std::optional<...> — the broader "has an empty state" test the rule vocabulary uses. |
Condition<Cond> |
concept | true when Cond declares static constexpr bool isCondition = true — the admission test for a nested condition. VisibleWhen/ReadonlyWhen/RequiredWhen deliberately do not declare it; see What may be a condition. |
ComparableAgainstLiteral<V, L> |
concept | true when a field of type V can be compared against a literal of type L at all. Constrains both equals overloads, so an incomparable pairing is an error at the call site. |
RuleLiteral<L> |
concept | The closed set of literal types equals accepts: std::int64_t, bool, std::string, math::Rational, and a FixedString captured inline. It is what makes a literal serialise losslessly into x-rules; a type outside it is rejected at the call site rather than at schema-emission time. |
RuleList<Rules...> |
class template | Holds an action's declared rules, in declaration order. Built by ruleList(...); never constructed directly. |
ruleList(rules...) |
function template | Composes rule/condition nodes into the RuleList an action assigns to formRules. |
HasFormRules<A> |
concept | true when A declares a static constexpr formRules member. |
allRulesSatisfied<A>(action) |
function template | true when every validation rule in A::formRules holds (or there are none); skips presentation rules. noexcept. |
engaged/notEngaged/equals/greater/greaterOrEqual/less/lessOrEqual/requiredWhen/exactlyOneOf/atLeastOneOf/mutuallyExclusive/visibleWhen/readonlyWhen/andOf/orOf/notOf |
function templates | Factories building one typed rule/condition node each; see the kind table above. |
UnsatisfiableFormError |
struct (std::logic_error) |
Thrown by schemaJson<A>() when a capping rule ranges over two or more fields A also makes required. Its what() names the action type, the rule kind, and the offending fields. |
detail::capsEngagedCount(kind) |
function | true for the emitted rule kinds that impose a ceiling on how many of their fields may be engaged ("exactlyOneOf", "mutuallyExclusive"). The single place those kinds are named. |
detail::findUnsatisfiableConjunct(nodes, requiredNames) |
function | The first capping node in a conjunction of emitted nodes that ranges over two or more names in requiredNames, as (kind, offenders). Recurses into an and node's conditions; deliberately not into or or not. |
detail::rejectUnsatisfiableRules<A>(xRules, requiredNames) |
function template | Throws UnsatisfiableFormError for whatever findUnsatisfiableConjunct returns over the emitted x-rules array. Called from mergeSchemaExtras. |
detail::ConditionActionType<Cond> |
alias template | The action type A a condition/rule node's test(const A&) const noexcept ranges over, deduced from &Cond::test's member-function-pointer type. Used by andOf/orOf/notOf to recover A without every leaf node separately naming it — and, since the recovery reads the first operand only, to require every other operand to agree. |
| Signature | Returns |
|---|---|
template <auto Dst, auto... Inputs, typename Fn> auto computed(Fn fn) |
A detail::ComputedField<Dst, Fn, Inputs...> value. |
template <typename... Fields> auto computeList(Fields... fields) |
A detail::ComputeList<Fields...> value — assign to static constexpr auto computedFields. |
template <typename A> void recomputeAll(A& action) |
Overwrites every A::computedFields destination in place; a no-op when A declares none. |
| Decision | Choice | Why |
|---|---|---|
| Required default | All members required unless explicitly opted out | The safer default for domain forms — forgetting to mark a field optional would leak data, not lose it. Opt out via std::optional or optionalFields list. |
| Optional mechanism | Two orthogonal opt-outs | std::optional<T> handles library types (glaze already knows how to serialise them); optionalFields handles custom types like Quantity whose emptiness is not expressed through optional. |
| Schema caching | static const std::string inside the template |
Same schema for the same type in every translation unit. No synchronisation needed — schema generation does not mutate anything. |
| Failure mode | Returns raw glaze schema (or empty) rather than throwing | Schema generation is a description facility; crashing a server over a malformed schema would be wrong. |
| Unsatisfiable declaration | Rejected at generation with UnsatisfiableFormError, the one exception to the row above |
A capping rule (exactlyOneOf/mutuallyExclusive) over two or more required fields yields a form nobody can submit, not a form missing a hint — the failure is certain at generation time and belongs to the author's build, not to a user who cannot press Save. See Unsatisfiable declarations. |
Choice metadata |
In the type, not the payload | The set of options for a field is a compile-time property of the action, not a runtime property of each submission. The generated schema communicates it to the client; payloads carry only the selected value. |
| Wire serialisation | Glaze meta reflects value directly |
Choice<T, ...> serialises as T | null — the options metadata never travels. |
| Options action | A registered action type id | The same action dispatch mechanism handles queries for picklist data, so no separate protocol or endpoint is needed. |
Dependent Choice options |
Sibling values as the options-action request body, not a new dispatch mechanism | Choice's DependsOn pack only changes what body a renderer sends; the options action stays an ordinary registered action reached through the same executeJson/ActionDispatcher seam as every other action, so multi-parent cascades and independent Choices coexist with no new framework surface. |
x-order |
Always emitted, on every property | JSON object key order is not reliable across DOM implementations; the explicit index gives renderers a deterministic layout. |
| Cross-field rules | Closed, typed vocabulary, one declaration → schema + client + server | Client and server must evaluate cross-field conditions identically; a closed set of framework-owned node types (not application lambdas) is what makes that possible. Arbitrary logic that does not fit stays in validate()/execute, unreflected into x-rules, exactly as allRequiredEngaged already draws the line for per-field required-ness. |
x-unitAlternatives |
Derived from UnitTraits::relations |
The same UnitRelation entries that drive convert also drive the display-unit selector — no separate declaration to keep in sync. |
Timestamp |
Uses standard "format": "date-time" |
No extension annotation needed; standard JSON-Schema vocabulary is sufficient. |
| Layout declaration | static constexpr formLayout / fieldSpans, mirroring optionalFields |
Visual structure is a compile-time property of the action, exactly like the existing opt-out list; a renderer that ignores it degrades to the flat x-order form with no missing fields. |
| Widget selection | Type-derived by default (Multiline/Ranged), fieldMetadata-shaped override wins |
Mirrors the Choice/Quantity pattern: the control is a compile-time property of the type; the escape hatch is a typed declaration, not a schema-only knob. |
| Widget override lookup | Duck-typed on .field/.widget, not a named type |
Keeps forms.hpp's widget lookup free of a hard dependency on any one field-metadata descriptor type declaration; any shape exposing those two members is honoured, FieldMeta (above) included. |
| Computed fields | One declaration (computed/computeList) drives schema + client + server via a single shared recomputeAll |
The same evaluator runs on the client dispatch paths (executeJson, Bridge::executeVia) and on every server dispatch path, so the displayed value and the stored value are derived identically — a computed field can never drift, and the server never trusts a client-submitted derivation. |
A member whose type is itself a reflectable aggregate — a plain nested
struct, or std::vector<Sub> (a repeated aggregate) — gets its own
members annotated too: x-order, title/FieldMeta, required, and the
Quantity/Choice/widget/ranged-bounds rules the top level already applies.
Unlike the top level, this recurses into the type graph — a nested
aggregate's own nested-aggregate member is annotated in turn, and so on, to
whatever depth the type graph has — rather than stopping after one level. This
closes the gap a flat-only generator has for domains that are naturally
nested (a measurement with a repeated specimen sub-record, a document with a
nested address, a category tree), including domains nested more than one
level deep (an address with a nested geo-coordinate sub-record, say).
A std::optional<Sub> member is recursed into as well: glaze wraps Sub's
schema in {"anyOf": [<Sub>, {"type": "null"}]}, and the non-null branch is
annotated like a plain member's node. Two schema shapes exist for a nested
aggregate, and both are recursed into:
- Deduplicated (
$ref/$defs) — glaze shares one$defsentry,$ref'd from every property, when the nested type is used two or more times anywhere in the schema. The shared$defsentry is annotated once; every property that$refs it sees the same annotations. - Inlined — glaze writes the object schema directly into the property
itself (no
$ref/$defsat all) when the nested type is used exactly once. The property node itself is annotated in place.
mergeSchemaExtras resolves whichever form applies (annotateNestedAggregateRef,
forms.hpp) and hands the resolved node to the same per-member annotation
logic the top level uses (annotateBasicMemberProperty), applied against the
nested type's own reflection. Each recursive step passes along one piece of
state: a runtime set of the $defs keys already annotated. That set is what
stops the walk, and it is also what keeps a shared nested type from being
annotated once per route to it. Nothing is carried in the type system.
Each step also carries the whole DOM alongside the node it is annotating,
because resolving a $ref means looking its key up under the DOM's $defs.
Those two were both plain glz::generic_u64& and adjacent in the parameter
list, so transposing them at a call site compiled silently and annotated
against the wrong root — a defect with no diagnostic of any kind. The DOM is
therefore passed as detail::SchemaDomRef, a non-owning handle whose only job
is to be a different type from a node, which turns that transposition into a
compile error. It is the remedy for what bugprone-easily-swappable-parameters
reports on this signature, rather than a suppression of the report.
Instantiations are per type, and that is the whole termination argument.
The recursion originally carried the ancestor chain as a variadic template parameter pack,
which made annotateNestedAggregate<Leaf, Ancestors...> a distinct
instantiation for every distinct root-to-node route through the type graph. A
domain model shaped like a tree has one route per node; a model shaped like a
DAG — an Address under both a Customer and a Supplier, a Money
everywhere — has as many as it has paths, and that count grows exponentially in
the graph's depth. A depth counter in place of the chain collapses that to one
instantiation per (type, depth) pair. Measured on a
fixture with 27 types over 8 levels, where
6,561 routes reach the deepest node (tests/compile_checks/forms_dag_probe.cpp,
g++ 16.2.1, -std=c++23 -fsyntax-only, CPU seconds): 26.8 s with the ancestor
chain against a 2.7 s control that has one route per node, and 3.0 s against
the same control with the depth counter. tests/compile_checks/forms_dag_budget.cmake
is the ctest guard that keeps it that way, asserting the DAG fixture costs no
more than three times its one-route control.
The depth counter is gone too, and the recursion carries no
template argument that varies down it. recurseIntoNestedAggregateIfAny<Member>
reaches annotateNestedAggregate<Sub> reaches
recurseIntoNestedAggregateIfAny<Member'>: every specialisation is keyed on a
type alone, the reachable type set of any program is finite, and a
specialisation already on the instantiation stack is not instantiated again. So
instantiation terminates even for a cyclic type graph, and the count drops
from one per (type, depth) pair to one per type — a further factor of the
graph's depth on a DAG. The forms_dag_budget.cmake guard is unchanged and
still passes: it asserts a ratio and does not care how the ratio is achieved.
Run back to back on one (loaded) machine, clang 22.1.8, best of 2: 108% of
control with the depth counter, 66% without it. The absolute millisecond counts
in those two runs differ by a factor of two in the control, which is the whole
reason this guard asserts a ratio and takes a minimum — read the two percentages
as "unchanged or better", not as a 1.6x speedup.
The $defs set is the runtime half of the same observation: a nested
aggregate's annotations are a function of its own type alone, so a shared
$defs entry reachable by several routes used to be rewritten with
byte-identical content once per route. It is now annotated by the first route
to reach it, and later routes return immediately. The emitted schema is
unchanged either way — that is what makes the skip safe.
There is no depth limit, and a cyclic type is described rather than
rejected. This paragraph used to say the opposite — that a cycle "cannot be
supported at all: the schema it describes has no bottom" — and that claim was
measurably wrong about the schema. glaze emits a finite, well-formed document
for a self-referential type by pointing $ref back at the $defs entry, and
morph's annotator walks that document rather than the type graph, so it visits
the entry once and stops. What stops it is the runtime $defs visited set,
which a cyclic type always reaches: glaze inlines a nested type only when it is
used exactly once in the whole schema, and a type reachable from itself never
is.
Measured with clang 22.1.8, -std=c++23, glaze v7.4.0 — three actions that a
depth-carrying recursion rejects with a hard static_assert compile here, and
the generated schema is annotated correctly:
struct TreeNode { std::string name; std::vector<TreeNode> children; };
struct Bee;
struct Ay { std::string tag; std::vector<Bee> bees; };
struct Bee { std::string tag; std::vector<Ay> ays; };schemaJson<SelfAction>(), for an action with a TreeNode member, emits
"$defs":{"probe::TreeNode":{"type":"object","properties":{
"children":{"type":"array","items":{"$ref":"#/$defs/probe::TreeNode"},
"x-order":1,"title":"Children"},
"name":{"type":"string","x-order":0,"title":"Name"}},
"additionalProperties":false,"required":["name","children"]}}— one $defs entry, self-referential by $ref, with x-order, title and
required applied exactly as for an acyclic nested aggregate. The mutually
referential Ay/Bee pair behaves the same way, and a straight 20-level
acyclic chain — four levels past the old cap — compiles too, on this toolchain
(see Nesting depth in practice: MSVC stops
lower). Compiled against
the previous revision, the identical fixture fails with three
static assertion failed ... '16UL < kMaxNestDepth' errors. That control is
what says the change is responsible for the difference, rather than the fixture
having been compilable all along.
kMaxNestDepth, the static_assert and the 16-level cap are therefore gone.
A cap is needed only because a depth counter cannot tell a cycle from a deep
graph; with nothing carried in the type system there is nothing to bound, and
so no limit to state.
morph imposes no depth limit. The compiler does, and on one of the three supported toolchains the ceiling is low enough to matter, so it is recorded here rather than discovered again from a build failure.
mergeSchemaExtras<A> opens with A probe{}, and for an action rooted at a
chain of nested aggregates that single initialiser is as deeply nested as the
chain. MSVC caps that. Measured by bisecting a reduced
struct Deep0 { int leaf = 0; };
struct Deep1 { Deep0 inner; }; // ... through DeepN
template <typename A> int make() { A probe{}; return 0; }
int sink = make<DeepN>();| toolchain | deepest accepted | how it fails |
|---|---|---|
cl 19.44, 19.50, 19.51 (/std:c++20 and /std:c++latest) |
15 nested initialiser levels | 16 → fatal error C1054: compiler limit: initializers nested too deeply |
| clang 22.1.8, and clang-cl | ≥ 700 | segfaults at 800 (stack exhaustion, machine-dependent) |
| g++ 16.2.1 | ≥ 800 | not reached |
Two things about MSVC's number. It is specific to an instantiated template:
the same chain initialised at namespace scope compiled at 120 levels without
complaint, so it is not a limit on aggregate nesting as such but on the
initialiser MSVC builds while instantiating. And it is lower than the
16-level cap a depth counter would impose — a 15- or 16-level chain hits
C1054 on MSVC ahead of any static_assert meant to be the diagnostic. Nothing
in the repository nests more than three levels, so nothing in the tree reaches
either limit.
The action type counts as one of the 15, so cl accepts a chain of 14
below it. tests/test_nested_forms.cpp uses 20 (four past the removed cap)
everywhere and 12 on MSVC — one link of margin — selected at kDeepChainLevels
with the measurement in a comment beside it.
C1054 is a worse diagnostic than the static_assert it replaced: it names
neither the action type nor the nesting, and points at forms.hpp's A probe{}
rather than at the domain type responsible. That is a real cost of removing the
cap, and it is not recoverable — morph cannot detect a limit the compiler does
not expose. What is recoverable is knowing the number, which is what this
section is for.
Verification status. The per-toolchain numbers above are measured, on the
reduced probe, not on morph's own headers: cl via Compiler Explorer's
19.44/19.50/19.51 (CI runs 19.51.36256.0), clang and g++ locally. The
consequence for morph's real fixture — that a 12-link chain under
DeeplyNestedAction compiles on cl — is inferred from the reduced
measurement plus one link of margin, and is confirmed or refuted by the next
Windows / cl-* run.
Nothing executes these numbers: they are a record, not a check, so a
toolchain upgrade that moves MSVC's limit down would be found by a red
Windows / cl-* leg rather than by a named guard. A compile-check on the model
of forms_dag_budget.cmake would close that, and does not exist.
A "diamond" was never affected and still is not — the same type reused from two
unrelated places in the schema, e.g. an Address nested under both a Company
and a Person member of the same action, recurses normally into both and, per
the $defs set above, is annotated once rather than twice.
What is not claimed here: that DynamicForm draws a recursive form. This
section is about schema generation, and the measurement above is about schema
generation. What the shipped renderer does with the document it produces is a
separate contract, stated next.
The shipped MorphForms renderer renders flat actions. A nested-aggregate
member — $ref-cyclic or not — is not drawn as a sub-form; it is flattened to
a single scalar control at the parent level, and its own members reach no
control at all. That is measured, and pinned by
src/qt/forms/tests/tst_DynamicFormNestedAggregate.qml.
Four statements, each asserted by that suite:
- A
$refcycle neither loops, hangs nor crashes the renderer.resolveRef(src/qt/forms/qml/DynamicForm.qml) follows a$refexactly one level and merges the$defunder the property; it never recurses, so a cycle is not a loop. TheTreeNode/SelfActionschema quoted above builds a two-field form. - An object-typed member becomes one plain text field. For that schema the
renderer produces
field_idandfield_root, bothTextFields, and no control forTreeNode's ownname/children. - An acyclic nested aggregate is flattened identically. An inlined
addressobject withstreet/citymembers yieldsfield_addressand nothing forstreetorcity. The cycle is not what stops the renderer — nesting is. - The form does not report itself ready, and says which member stopped it.
The flattened control collects text, and text is a JSON string where the
schema asks for an object; a recursive collection member takes the
type: "array"control, which encodes each entry as a string where the schema asks for an array of objects. Neither member has an encoding, so both are unrepresentable (see Whatreadyclaims): while the payload would have to carry one,readyisfalse,previewLineis empty,submitIfValidis never called, andunrepresentableReasonnames the member. An optional nested member left blank is not an exception to this: the body omits it, and that is a body the schema accepts.
Measured on Qt 6.11.2, QT_QPA_PLATFORM=offscreen, against the real
DynamicForm with a mock controller. The suite was shown to measure the
renderer rather than pass vacuously, on each half of point 4 separately: of its
7 cases, removing fieldJsonLiteral's unrepresentable check — which restores a
ready of true for both wrong payloads — turns 4 red on the ready
comparison, and removing revalidate()'s unrepresentableReason assignment
turns the same 4 red on the reason instead. What stays green under both is the
two structural cases — the cycle builds a form, and the member is one control
rather than a sub-form — and the flat form, which is what makes those four
failures a statement about readiness rather than about the suite.
Point 4 is the readiness contract applied to a nesting the renderer does not draw, not a concession to it. A renderer may legitimately decline to draw a member it cannot represent; it may not then report that the resulting body is acceptable. What stays undecided is the other half — whether the renderer should draw the sub-form, decline the schema with a diagnostic, or keep flattening — and the readiness answer is the same under all three, so it does not wait on that. Nothing in this repository has a nested-aggregate member today, so nothing depends on the rendering answer yet.
So: an action with a nested-aggregate member — cyclic or otherwise — is a document morph generates completely, a form morph draws only down to the nesting, and a body morph declines to assemble — unless the member is a top-level collection of objects or a top-level nested object a host slot draws, which the form then encodes row by row or member by member (see Collections of objects and Nested objects). Everything above describes a member no slot claims, and is unchanged by that.
Computed fields, formLayout/fieldSpans, and formRules remain top-level
only regardless of nesting depth: a nested aggregate declaring any of those
has no effect on the generated schema. This keeps the generator focused on
what a nested-aggregate schema actually needs (per-field annotations) rather
than becoming a general recursive-descent schema compiler that also
re-derives layout/rules/computed-field semantics at every level.
Purely additive, with one source-compatibility exception. An action with no nested-aggregate member has nothing here to trigger on, so its generated schema is byte-for-byte unchanged. A pre-existing action that does have a nested-aggregate member sees its schema gain annotations it previously lacked — the whole point of this feature — with no change to any of its flat top-level members. Neither a deeply nested action nor a self- or mutually-referential nested-aggregate member fails to compile, and no action in this repository is in that position in any case.
Every nested-aggregate type in the chain must be default-constructible, exactly like the top-level action type (see below): the recursion builds its own probe instance purely to enumerate its members via reflection.
formLayout/fieldSpans (Layout & grouping) and
formRules (Cross-field rules) are read only
from the top-level action type — they are not consulted on a nested
aggregate, no matter how deep mergeSchemaExtras otherwise recurses (see
Nested aggregates (recursive, cycle-safe)
above). Computed fields (computedFields) are likewise top-level only.
The action type must also be default-constructible: mergeSchemaExtras
builds a probe instance (A probe{}) purely to enumerate member names and types
via reflection. A type with no accessible default constructor will not compile
schemaJson<A>().
schemaJson<A>() calls mergeSchemaExtras<A>(glz::write_json_schema<A>().value_or(std::string{})).
When glaze's own schema writer fails, value_or hands mergeSchemaExtras an
empty string; read_json then fails on it and the function returns that
same empty string. So a total failure surfaces as "" — not valid JSON, not
an empty JSON object {}. Renderers must treat an empty string as "schema
unavailable" and refuse to build a form from it. Because the fallback path
carries no diagnostic, an empty result is indistinguishable from any other
failure mode (there is no error code, message, or partial schema to inspect).
The empty string is the only failure signal a caller receives on this path; the
one case in which schema generation throws instead is a self-contradicting
declaration (Unsatisfiable declarations),
which is an author error in the action type, not a failure of the input schema.
Validation is enforced on every dispatch path — client bridge, local in-process, and the server-side wire dispatcher:
BridgeHandler::executeJson→ActionExecuteRegistry(the local / client-side path a schema-driven GUI uses). EnforcesActionValidator<Action>::readyafter decoding and before invoking the handler (see bridge.md): an action that failsvalidate()is rejected with an error, never executed. It also retagsQuantityfields to their declared precision (below).Bridge::executeVia'slocalOp(LocalBackend, reached by any hand-builtActionpassed toBridgeHandler<Model>::execute<Action>()directly). Enforces the sameready()check beforeModel::execute, rejecting viaonErrorwith amorph::model::ValidationError(see bridge.md).RemoteServer/ActionDispatcher(the server-side wire path, remote mode).ActionDispatcher::registerAction's runner reconciles declaredQuantityprecision and enforcesActionValidator<Action>::readybeforeModel::executeruns, throwingmorph::model::ValidationError(astd::runtime_errorsubclass caught byRemoteServer's strand and turned into anerrreply) when it returnsfalse(see registry.md).
So the schema's required array and allRequiredEngaged are enforced
consistently on every dispatch path — schema, form, local execution, and the
remote wire path all agree. Validation is not authorization, however: a
validated action can still be rejected by IAuthorizer, and vice versa — see
security.md for that separate seam. A model may also still
enforce deeper business rules validate() cannot express (cross-entity
constraints, balance checks); validate() only covers field-level readiness,
which is why the examples/forms model additionally calls
action.validate() itself inside execute(RecordMeasurement) and throws
std::invalid_argument on failure as a defense-in-depth model-level check.
Because allRulesSatisfied (above) is typically one of the two conjuncts of
validate(), a formRules declaration is enforced on exactly the same paths
ActionValidator<A>::ready already is — no separate enforcement seam.
x-decimalPlaces advertises a field's declared precision
(Quantity<U, Dec>::declaredDecimals), but a Quantity on the wire carries its
own runtime dp, which a client may set to anything. On the client bridge
dispatch path (executeJson → ActionExecuteRegistry) these are reconciled:
after decoding and before dispatch, morph::forms::reconcileDeclaredPrecision
rounds every Quantity member of the action to declaredPrecision(), so the
value the handler stores is at the precision the schema advertised, not at the
client's submitted dp. An empty Quantity stays empty. The reconciliation is a
no-op for actions with no Quantity members and for action types glaze cannot
reflect. ActionDispatcher::registerAction's runner performs the same
reconciliation on the server-side wire path (see
registry.md), so x-decimalPlaces is an enforced contract
on both wire paths — client-bridge and remote.
Rounding, not retagging — and why the difference is the whole point.
Quantity::atDeclaredPrecision() performs an exact Rational re-rounding
(math::roundToDecimalPlaces, half away from zero, matching the decimal
formatter — see
rational.md).
It does not merely move the DecimalPlaces tag. A tag-only change would
leave a field declaring dp = 1 holding exactly 1.23456 while rendering
1.2: the report says one number and the database another, and an audit trail
cannot say which one the operator saw when they signed off. For a framework whose
premise is exact values for financial and lab data, that failure mode is worse
than not enforcing at all — the value looks compliant. Enforcement therefore
means the submitted precision beyond the declared amount is discarded, not
hidden, and the reconciled value is by construction the value the form was
already displaying.
The operation normalises rather than rejecting an over-precise submission. Two
reasons. A wire dp finer than the declared one is not by itself a protocol
violation — {"num":6,"den":5,"dp":5} is exactly 1.2, perfectly representable
at dp = 1, and rejecting it would fail a payload with nothing wrong in it;
detecting the genuinely over-precise case means testing the value, not the tag.
More decisively, the same atDeclaredPrecision() call is what recomputeOne
applies to server-derived values (see Computed fields),
which routinely carry more decimals than the destination field declares — a
product of two 2-decimal operands is exact to 4. A reject-shaped contract would
have the server reject its own arithmetic. Normalising is the only rule both call
sites can share.
The in-process path is deliberately not reconciled. Bridge::executeVia's
localOp decodes no JSON — it dispatches an already-typed Action a caller
constructed in C++ — so there is no client-supplied dp to reconcile and no
reconciliation step there (bridge.hpp says so at the call site; cross-ref
quantity_type.md, which describes the same asymmetry
for enforceQuantityBounds). A Quantity built by calling code keeps whatever
precision the caller gave it. Computed fields are still normalised on that
path, since recomputeAll runs there and recomputeOne rounds to the
destination's declared precision.
Reconciling declared precision (above) still leaves a wire payload that is
merely representable, not necessarily physically or contractually
sensible — a percentage of 250, a mass of -5. morph::forms:: checkQuantityBounds<A>(action) closes that gap: it walks every reflected
Quantity member of a decoded action and checks
Quantity::withinDeclaredBounds() (see
quantity_type.md, "Pre-decode wire validation")
against the optional UnitTraits<E>::bounds(E) a unit may declare. It returns
the wire name of the first offending field, or std::nullopt — a no-op for
actions with no Quantity members, or whose units declare no bounds.
morph::forms::enforceQuantityBounds<A>(action) is the throwing counterpart,
raising morph::forms::QuantityDecodeError naming that field.
Both wire-decoding dispatch runners — ActionDispatcher::registerAction's
server-side runner and ActionExecuteRegistry::registerAction's client
bridge runner (see registry.md/bridge.md)
— call enforceQuantityBounds immediately after reconcileDeclaredPrecision
and before recomputeAll/ActionValidator<A>::ready, so an out-of-bounds
wire value is rejected before an action's own validate() ever sees it.
QuantityDecodeError is deliberately not morph::model::ValidationError
— the two error types stay distinct so a caller can tell "the wire payload
itself was impossible" (a decode-level, framework-enforced constraint) from
"the decoded action failed its own business rule" (a validate()-level
rejection an action author wrote). The in-process Bridge::executeVia
localOp path is unaffected — no JSON decode happens there (see "Advertised
precision is enforced on dispatch" above for why reconcileDeclaredPrecision
is likewise skipped on that path), so a Quantity a caller constructs
directly carries whatever value the caller gave it, unchecked at this seam.
The forms vocabulary provides no native sum-type (tagged union, discriminated union) support. When an action field must express one of several alternatives (e.g. a measurement that is "a quantity, or below limit-of-detection, or above upper detection limit"), encode it as a multi-field structure glued by cross-field rules: one field for the quantity, one boolean or enum for the state (measured/below/above), and a RequiredWhen/VisibleWhen rule that gates each based on the others. This is by design: sum types are rare in domain models that already use hasValue() optionality and Choice enums, and the rule-based multi-field encoding is expressive enough for the rungs' needs while keeping the schema and validation machinery focused.
The encoding carries one obligation: every field the capping rule ranges over must be named in A::optionalFields, so the rule is the only gate on them. Omitting that leaves required demanding every alternative at once, which contradicts the rule — schemaJson<A>() rejects it rather than serving an unsubmittable form (Unsatisfiable declarations).
schemaJson<A>() derives every key from the compiled type, so two rows that
describe the same action differently cannot be told apart by it.
InstanceConstraints (above) lifts that for the keys it covers —
x-decimalPlaces and the exact bounds — but only for their values. Which
fields exist, the required array and the x-rules list remain functions of
A, so a definition wanting a different shape still needs a recompile, and
an application with per-definition shapes needs one compiled action per family
of shapes rather than one per definition. Applying an instance constraint also
stays the model's job rather than the dispatch runner's; see
instance_constraints.md, "Why dispatch cannot apply
these automatically", for why neither of those is an oversight.
Each type's schema is memoised in a function-local static const std::string
(schemaJson<A>()), computed by the first caller and shared process-wide
thereafter (first-caller-wins; no synchronisation, since generation mutates
nothing). This baking-in precludes localised / i18n schemas: the human
display strings that land in the schema (unit display/unitUnicode, and any
description text) are fixed at first call. There is no per-request or
per-locale schema variant — a translated form would need a different mechanism
entirely. See Localisation — message keys and the catalog seam
for that mechanism.
The generator is coupled to concrete details of glaze's schema output shape, not just its public API:
- A top-level
propertiesobject.mergeSchemaExtrasindexesdom["properties"][name]directly. If glaze stopped emitting apropertiesobject at the root (or nested it), the merge would create the wrong structure. $defsnumeric-bound preservation viageneric_u64. The DOM is parsed in u64 number mode specifically because$defscarriesint64/uint64bounds that the default double-only DOM would silently round. This depends on glaze emitting those bounds as integers in a place the round-trip preserves.- Reflection key order feeding
x-order.x-orderis the indexIfromglz::reflect<A>::keys, and it is trusted to equal source declaration order. If glaze's reflection reordered keys,x-orderwould misdescribe the layout while still looking well-formed. - A
glz::enumeratedenum classas aoneOfofconsts. A renderer recognises a closed set by that shape alone (see Closed sets); morph stamps no key of its own to mark it. If glaze emitted such an enum some other way — a bareenumarray is read too, but a third spelling would not be — the member would silently fall back to a free-text field.
A change to any of these in glaze could make the generator silently produce a wrong or un-merged schema rather than fail loudly.
| Spec | Why |
|---|---|
| choice.md | Full Choice API and design (this spec cross-refs rather than duplicates it). |
| instance_constraints.md | InstanceConstraints — serving and checking the framework-meaningful keys whose values live in data rather than in the compiled type. |
| workflows_navigation.md | The wizard/app-shell layer built on this schema — one wizard step or one kind: "form" app screen still renders as an ordinary action form. |
| views.md | The view-schema layer (morph::views) that composes query+edit+delete action sets into list/table and master-detail screens; reuses schemaJson<Row>() unmodified to derive each column's ExtUnits/x-decimalPlaces. |
| widget_hints.md | Full Multiline/Ranged API and design (this spec cross-refs rather than duplicates it). |
| quantity_type.md | Quantity, its unit tags, UnitTraits::relations, and convert — the source of x-decimalPlaces, x-unitAlternatives, and ExtUnits. |
| datetime.md | DateTime / Timestamp, the ISO-8601 wire format, and the "format": "date-time" schema annotation. |
| rational.md | Exact Rational values; the num/den in each x-unitAlternatives entry are a Rational numerator/denominator, which is why unit switches recompute exactly. Also the comparison/equality greater/greaterOrEqual/less/lessOrEqual/equals use for numeric fields, so client and server compare identical values. |
| security.md | The dispatcher's trust boundary — why required gates only the client and handlers must re-validate. |
| session.md | Context::locale, the server-side hook for data (not chrome) localisation — the one place session::current()->locale participates, for Choice option-row labels. |
| bridge.md | The ActionExecuteRegistry/executeVia authoritative recompute sites. |
| registry.md | ActionDispatcher::registerAction's runner — the server-side authoritative recompute site. |
- Generating schemas for non‑action types (the module assumes glaze reflection is
available on
A). - Validating payloads against the schema — the schema is for the client.
- Executing the options action —
choicesmetadata tells the client which action to call, but the forms module does not invoke it. morph::time::Timestampdefinition — it is only consumed here viaEmptyCapableField.