Skip to content

Latest commit

 

History

History
3641 lines (3143 loc) · 233 KB

File metadata and controls

3641 lines (3143 loc) · 233 KB

morph::forms — schema generation & readiness for action types

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.

Design principle: infer by default, declare to override

Every feature below obeys one rule, which is what reconciles "rapid GUI development" with "flexible when the generated form isn't enough":

  1. Infer from the type where possible. A Quantity field already knows its unit and precision; a Choice already knows its options action; a std::optional already means "not required." The renderer gets as far as it can from types alone, with zero extra user declaration.
  2. 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 constexpr member or a small registration macro on the action (these macros are hand-aligned behind // clang-format off; the rationale lives once in CONTRIBUTING.md under Formatting/linting, and each site carries a pointer rather than a copy). Never mandatory; absence falls back to a sensible convention.
  3. 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.

Contents

Empty state — EmptyCapableField concept

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() returns true when the Rational payload is present.
  • morph::forms::Choice<T, ...> — hasValue() returns true when its std::optional<T> is engaged.
  • morph::time::Timestamp — hasValue() returns true when its DateTime payload is present.
  • morph::util::Tagged<T, Tag> — hasValue() always returns true: 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 (see tagged.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.

Choice — server-sourced picklist

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_t for ids, string for codes).
  • OptionsAction — the registered action type id whose result provides options (executed with an empty body when DependsOn is empty, or with {name: value, ...} built from the DependsOn names 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.

FixedString — NTTP compile-time string

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.

Widget hints — Multiline / Ranged

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.

schemaJson<A>() — schema generation

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-ness rule

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:

  1. Its type is std::optional<...>, or
  2. Its name appears in A::optionalFields — a static constexpr iterable of std::string_view that the action declares, or
  3. It is the destination of an A::computedFields entry — 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); }
};

mergeSchemaExtras — DOM post-processing

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.

Reading the DOM with findMember

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 items node?", "does $defs hold this key?" — must not create anything. A read that inserts adds a null member to the schema being emitted, and (because glz::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.

Field metadata — FieldMeta

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.

Label inference

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<&Action::field>(...) — deriving the field name from the member

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:

  1. 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 probe RecordMeasurement instance, which an incomplete type cannot do.
  2. glaze's reflection is not constexpr for reflectable aggregates. glz::get_member, which detail::forEachNamedMember calls, is an ordinary runtime function — so even resolving the name outside the class cannot happen inside a constexpr/consteval function.

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.

Per-field scalar bounds — minimum / maximum / multipleOf

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.

Display unit and decimals for a plain member — unit / decimals

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"),
    };
};
  • unit is emitted as ExtUnits, the key a Quantity's unit already travels in, with the one string in both unitAscii and unitUnicode. Every reader of a unit therefore finds a plain member's where it finds a Quantity's: the shipped renderer's unit suffix, SlotRegistry.byUnit, and a view's v-columns entry (views.hpp copies ExtUnits off the property node). It is presentation only: nothing converts through it, and it never reaches the payload.
  • decimals is emitted as x-displayDecimals, deliberately not x-decimalPlaces. x-decimalPlaces hands a property the exact {num,den,dp} encoding (see Plain number fields: a declared precision wins over the "number" type), and a double member cannot decode that object. x-displayDecimals keeps 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 no x-decimalPlaces.
  • Both are ignored on a Quantity member, whose unit and declared precision are part of its type and already emitted; a second declaration could only disagree with the first. A decimals above kMaxDecimalPlaces is ignored too, as a non-positive multipleOf is.
  • Both apply at any depth, like the rest of FieldMeta: a std::vector<Row> element's own fieldMetadata stamps 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.

Clearing a string in an edit form — blankAs

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. DynamicForm reads it only for a field of kind string, so a number, a closed set, a Choice or a Timestamp ignores it. None of those has a "" spelling.
  • required is 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.

Field metadata is not a security control

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.

Emitted keys

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).

Layout & grouping — sections, tabs, spans

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 = full

A 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.

Renderer contract: the schema key vocabulary

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.

Where the keys physically land — $ref resolution is mandatory

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:

  • ExtUnits lives in the $def of the unit type — glaze stamps it onto the Quantity's type definition, not onto the property. Many properties of the same unit type share one $def and therefore one ExtUnits.
  • x-order, x-decimalPlaces, x-unitAlternatives, x-optionsAction / x-optionValue / x-optionLabel / x-optionsDependsOn are siblings of the $ref on this property — mergeSchemaExtras patches dom["properties"][name], which is the property node holding the $ref. This is still true for a Quantity/Choice property's own $def (the quantity_kg_per_m3-style def shown above never gets x-order/required/ title — only ExtUnits and glaze's own type/bounds/description live there). It is not true for a nested-aggregate member's $def: see Nested aggregates (recursive, cycle-safe) below — that $def does get required/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.

Explicit submit mode — x-submitMode

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 recomputes ready/previewLine live (so x-rules, required, and every other live-validation affordance are unaffected) but never calls submitIfValid on its own.
  • The renderer instead shows an explicit Submit button (objectName: "submitButton"), enabled only while ready — matching the existing x-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.

Declaring it from C++

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.

Array fields — type: "array"

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.

Collections of objects — a host slot draws them

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.

Nested objects — a host slot draws them

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.

Boolean fields — type: "boolean"

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.

Plain number fields — type: "number"

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.5 in 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 a float cannot hold is refused by the same check that enforces a FieldMeta bound. allFieldBoundsSatisfied does not check a double member — it reads Quantity, bare math::Rational and integral members only — but ready is 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.

Nullable fields whose type is a $ref — anyOf

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.

Closed sets — a reflected enum class

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 a Choice draws — the only difference is that the options are in the schema rather than behind x-optionsAction, so there is no options fetch and no round trip. A null branch 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 boolean field refuses anything but true/ false. This is what distinguishes a closed set from a Choice, 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.

Exact numeric bounds — x-exactMinimum / x-exactMaximum

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, a Ranged slider, a hand-written maximum: 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/maximum remain 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.

Versioning stance

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.

Shipped Qt/QML reference renderer

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/forms builds the QML module MorphForms (CMake target morph_forms_module, qt_add_qml_module(... URI MorphForms VERSION 1.0)): DynamicForm.qml (the Repeater-over-fields form renderer: $ref and anyOf resolution/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), and I18nCatalog.hpp/.cpp (a QObject/QML_ELEMENT in-memory TranslationProvider realization — see Localisation — shipped alongside the renderer rather than left in a demo, since it is model-agnostic and DynamicForm's catalog property consumes it structurally, not by name). It builds whenever -DMORPH_BUILD_FORMS_QML=ON, independent of MORPH_BUILD_EXAMPLES — an app depends on it directly (target_link_libraries(... morph_forms_moduleplugin) plus import MorphForms in its own QML) instead of copying or forking it. Installed, it is the forms_qml component: find_package(morph CONFIG REQUIRED COMPONENTS forms_qml qt_forms) and morph::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 no MorphForms at run time), plus qmldir/.qmltypes/sources under MORPH_INSTALL_QMLDIR for tooling, exported as morph_QML_IMPORT_PATH. The installed qmldir's linktarget names morph::forms_qmlplugin, the name a static-Qt build's QML plugin import resolves. scripts/check_forms_qml_install.sh (CI job install-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 (component qt_forms, which also ships the qt_executor.hpp it includes, so an install without MORPH_BUILD_QT still compiles it) ships morph::qt::forms::FormsControllerCore<Model>, a header-only, model-agnostic template (no Q_OBJECT — Qt cannot register a class template for QML) that owns or composes over the Bridge/BridgeHandler<Model>/QtExecutor wiring an app's own QObject/QML_ELEMENT controller subclass forwards to. Two constructors decide who owns the Bridge:

    • FormsControllerCore(schemasJson) builds and owns a private ThreadPoolExecutor + QtExecutor + Bridge over a LocalBackend — the convenient default for a demo or an app with no Bridge of its own.
    • FormsControllerCore(Bridge& bridge, IExecutor* guiExec, schemasJson) composes over a caller-supplied Bridge/executor instead of building a second, always-local one — the caller decides the deployment mode (LocalBackend, SimulatedRemoteBackend, QtWebSocketBackend, ...), and a later bridge.switchBackend(...) on that same Bridge is still reachable through this core's handler (the handler re-registers itself automatically, exactly like any other BridgeHandler). bridge and guiExec must outlive the core.

    It exposes schemasJson(), submitIfValid(actionType, bodyJson, onReply, onError), and fetchOptions(optionsAction, bodyJson, onReply, onError) — both operations dispatch generically via BridgeHandler::executeJson, so an app's controller never hardcodes one action, and fetchOptions's bodyJson is a true pass-through ("{}" for an independent Choice, or {parentField: value, ...} for a dependent one — see Choice — server-sourced picklist) rather than always empty; examples/forms/gui_qml/FormsController.hpp is 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-existing Bridge to compose over.

  • examples/forms/gui_qml is a consumer of the shipped module, not its home: its own LabFormsDemo QML module carries only Main.qml and the FormsController subclass naming lab::LabModel; Main.qml imports MorphForms for DynamicForm/I18nCatalog like any other consumer would.

  • include/morph/qt/forms/multi_model_forms_controller_core.hpp (same component, same install story) ships morph::qt::forms::MultiModelFormsControllerCore<Sharing, Model...>, the multi-model sibling of FormsControllerCore<Model, Sharing> for a rung whose forms span more than one registered model (bookmarks::gui::FormsBridge, whose forms serve AuthModel, BookmarkModel and TagModel, is the shipped example). Same schemasJson()/submitIfValid()/fetchOptions() surface, over morph::qt::bridge::MultiModelBridgeCore<Sharing, Model...> (include/morph/qt/bridge/multi_model_bridge_core.hpp) instead of GenericModelBridgeCore<Model, Sharing>: it composes one GenericModelBridgeCore<Model, Sharing> per Model in the pack and routes a submitted action-type id to whichever one serves it, via GenericModelBridgeCore::servesAction (which forwards to BridgeHandler::servesAction) — a pure existence check over ActionExecuteRegistry, the same registry executeJson dispatches through — rather than a hand-written actionType -> Model table. Models are tried in the order the pack declares them; an action id registered on more than one Model in the pack is a configuration bug asserted in debug builds, not a case routed silently. An unrouted action type resolves onError directly 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 a Choice field; a controller that serves none deliberately declares neither it nor fetchOptions() (bookmarks::gui::FormsBridge and pastebin::gui::FormsBridge each carry the reasoning: an unused fetchOptions() would be a stub with nothing to call it). Its block gates its target on the signal being declared — form.controller.optionsReceived !== undefined, else null — 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 about onOptionsReceived as 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 declare optionsReceived is connected to strictly, so a misspelling of the handler is still reported. ignoreUnknownSignals: true would silence that too, and the silence is expensive: the options never arrive, every Choice combo box stays empty, the form never reaches ready, 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.

Prefill — loading a stored payload for editing

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.

What ready claims

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::Rational member 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 of num/den/dp. Wrapping it in a Quantity — or giving the field an x-decimalPlaces — is what hands it the exact-decimal control that encodes that shape;
  • an array whose items are objects (or arrays), which takes the type: "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 what ready means.

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.

Renderer conformance kit

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), a Quantity with convertible alternatives (CFQuantityAlternatives), a Choice (CFChoiceField), a Timestamp (CFTimestampField), and two members of the same Quantity type sharing one $def (CFSharedDefFields) — each asserted against the real, generated schemaJson<A>() output (never hand-authored), so a change to mergeSchemaExtras/schemaJson that alters x-order, required, x-decimalPlaces, x-unitAlternatives, x-optionsAction/x-optionValue/x-optionLabel, format, or ExtUnits is 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 shipped DynamicForm: fields render in x-order; submission is blocked until every required field is engaged and enabled once they are; a Quantity payload is {num,den,dp} exact and a unit switch recomputes it exactly (no float drift); a Choice descriptor carries its declared x-optionsAction/x-optionValue/x-optionLabel; a Timestamp renders as a date-time control and gates on ISO-8601; two properties sharing one $def each keep their own x-order while resolving the same ExtUnits. The options-fetch itself — an independent Choice executing its options action with an empty body, and a dependent one (x-optionsDependsOn) with {parentField: value, ...} — is asserted separately, in src/qt/forms/tests/test_forms_controller_core.cpp (a Catch2 + Qt executable covering FormsControllerCore<Model> directly), since DynamicForm never 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 — title from 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 follows x-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 doubles BrokenOrderForm.qml/BrokenQuantityForm.qml, never shipped in the MorphForms module): a renderer that ignores x-order fails exactly the field-order assertion and no others; a renderer that silently rounds an over-precise Quantity entry 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).

Theming / component-override registry

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 registered SlotRegistry slot 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 matches SlotRegistry's byWidget tier — purely additive and ignorable.

  • SlotRegistry (QML type, module MorphForms, 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), and resolve(action, field, xWidget, unitAscii, jsonType, kind), which returns the highest-priority match or null (kind is optional; the five-argument call resolves as before). Resolution order is field → x-widget → unit → kind → type → built-in default. DynamicForm gains a slotRegistry property (null by default — no behavior change for an app that never sets it); when a field resolves to a registered Component, DynamicForm loads it via a Loader and 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 the Loader's sourceComponent being null). A registered slot Component implements one small contract: it declares property var field and property var setValue, both assigned by DynamicForm's Loader.onLoaded — field is the resolved, merged def+property descriptor, and setValue(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
    fieldText a binding to the field's retained text Seed and track the control's value. A prefill, resetFields(), a setFieldValue from code and a tab switch that rebuilds the slot all reach it; without it a slot sees only what it wrote itself.
    rows a binding to the rows of a collection of objects, as a JS array of {member: cellText} ([] when none) Draw the grid.
    setRows function (rows) Write the rows; equivalent to setValue(JSON.stringify(rows)).
    objectValue a binding to the value of a nested object, as a JS object of {member: cellText | nestedValue} ({} when blank) Draw the sub-form.
    setObject function (value) Write the object; equivalent to setValue(JSON.stringify(value)).
    form the DynamicForm itself Reach encodeFieldText(field, text, 0) (does this cell encode?) and the rest of the form's public surface.

    The bindings re-evaluate on rulesRevision, which revalidate() 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.revision is bumped on every by*() call and read inside resolve(), for the same reason I18nCatalog.revision exists: _byField/_byWidget/_byUnit/_byType are 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.

Chrome slots

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.

Localisation — message keys and the catalog seam

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::render is client-side only and never appears on the wire.

Message-key derivation

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.

The catalog seam

// 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.

Locale data formatting

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 text Quantity's exact digit routines already consume ("1050.25"); malformed input yields std::nullopt rather than a best-effort guess. formatCanonicalNumber(canonicalText, loc) is the display-direction inverse, with display-only thousands grouping. The exact Rational/Quantity digit 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 adjacent std::string_view parameters, every one silently swappable with its neighbours, and the header carried a clang-tidy suppression for bugprone-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 under bugprone-* 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 Nd with Numeric_Value 0 through 9 in code point order — so a single zeroDigit is sufficient and a ten-element table is not needed. Measured with QLocale::matchingLocales under Qt 6.11.2, over the same 711 locales:

    zeroDigit locales e.g.
    U+0030 604 C
    U+0660 26 ar_BH
    U+06F0 19 fa_IR
    U+1E950 12 ff_Adlm_BF
    U+0966 8 bgc_IN
    U+09E6 4 as_IN
    U+11136 2 ccp_BD
    U+07C0 1 nqo_GN
    U+0F20 1 dz_BT
    U+1040 1 my_MM
    U+1C50 1 sat_IN
    U+ABF0 1 mni_IN

    76 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, normalizeLocaleNumber compared 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. formatCanonicalNumber used 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 above zeroDigit, and the property is stated in those terms:

    for every canonical -?[0-9]+(\.[0-9]+)? text and every NumericLocale, 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] in tests/test_render_locale_format.cpp, and the test_thePairRoundTripsThroughEveryMeasuredDigitSet function in src/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 an ar_EG locale 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. When zeroDigit is 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 zeroDigit reads as ASCII "0", the reading an empty negativeSign gets 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. Because zeroDigit defaults 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, not char, 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 as char, 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 to std::nullopt and 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 locales QLocale::matchingLocales reports under Qt 6.11.2, 77 spell it as something other than a bare ASCII '-':

    negativeSign locales e.g.
    U+002D 634 C
    U+061C U+002D 24 ar_EG
    U+200E U+002D 9 ar_DZ
    U+200E U+002D U+200E 17 az_IR
    U+200E U+2212 2 fa_IR
    U+200F U+002D 2 ckb_IQ
    U+2212 23 eu_ES

    Matched 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. Note ar_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 wider char would 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 to negativeSign. 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 negativeSign means 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 -5 into 5: a valid number of the wrong sign, which is silent corruption rather than a rejection.

    The renderer passes the locale's own sign: DynamicForm.qml already binds qtLocale: Qt.locale(displayLocale) and forwards qtLocale.decimalPoint/qtLocale.groupSeparator, and now forwards qtLocale.negativeSign from the same object at all three call sites. The member is defaulted, so a caller that names only the separators is unchanged.

    displayLocale is a QLocale name; 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 the QLocale that Qt.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 enumerating QLocale::matchingLocales(AnyLanguage, AnyScript, AnyTerritory) — 711 locales, eleven distinct zeroDigit values — and then reconstructing a QLocale from each group's own reported name():

    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+1E950
    

    Nine of the eleven representative names round-trip; those two do not. QLocale("ff_BF") is the Latin-script Fulah and reports ASCII digits, while the matchingLocales entry whose name() is ff_BF is the Adlam-script one. The third row is the remedy and was measured through QML's Qt.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, not ff_BF. morph neither validates nor normalises displayLocale, and deliberately does not warn when Qt.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 through Qt.locale(displayLocale) is {C, de, eu_ES} — the property's default, the two entries of the example's ComboBox model (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 qtLocale object, 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, displayLocale is 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. normalizeLocaleNumber reads NumericLocale::positiveSign, matched exactly as negativeSign is — 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 over QLocale::positiveSign for the same 711 locales under Qt 6.11.2:

    positiveSign locales e.g.
    U+002B 657 C
    U+061C U+002B 24 ar_EG
    U+200E U+002B 11 ar_DZ
    U+200E U+002B U+200E 17 az_IR
    U+200F U+002B 2 ckb_IQ

    54 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. formatCanonicalNumber ignores NumericLocale::positiveSign entirely 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: positiveSign is '+' in 657 of the 711 locales, so emitting it would turn every positive number in every form from 5 into +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" into 15 — 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 groupSeparator equal to decimalSeparator is 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.qml carries 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 spelled text.startsWith(sign, i) there rather than ch === sign — a one-unit comparison could not match the 2–3 code point forms at all. The mirror's normalizeLocaleNumber takes positiveSign the same way and drops it the same way, and its formatCanonicalNumber takes none, for the reason above; the renderer forwards qtLocale.positiveSign at the two entry call sites that already forward qtLocale.negativeSign, and nothing changes at the display call site. All three call sites now also forward qtLocale.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 === groupSeparator and ch === decimalSeparator would be a one-code-unit comparison sitting a few lines from the whole-string sign match, with nothing saying why. They are text.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::matchingLocales under Qt 6.11.2, over the same 711 locales:

    field spellings longer than one UTF-16 code unit
    decimalPoint 0 of 711
    groupSeparator 0 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 groupSeparator spellings (U+0027, U+002C, U+002E, U+00A0, U+060C, U+066C, U+12C8, U+202F, U+2E41) and all three decimalPoint spellings (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 === sep and startsWith(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] in tests/test_render_locale_format.cpp, and the test_aMultiUnitSeparator* functions in src/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::DateTime with 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()->locale server-side (session.md) — the one place server-side locale participates.

Non-goals

  • No per-locale schema variants — schemaJson<A>() keeps its one cached, un-localised schema.
  • No translation storage format — the TranslationProvider signature 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.

allRequiredEngaged<A>() — readiness check

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.

Cross-field rules — the x-rules vocabulary

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.

The rule and condition kinds

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.

Compound conditions — andOf / orOf / notOf

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 when clause — requiredWhen/visibleWhen/readonlyWhen accept a compound condition in the same when position a leaf condition occupies, with no change to those three rule kinds themselves.
  • Directly as a top-level formRules entry — andOf/orOf/notOf declare isPresentation = false and a test(), so ruleList(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.

What may be a condition — the Condition concept

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.

Schema emission — nested conditions / condition

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.

Presentation rules never gate

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.

The x-rules schema emission

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.

Unsatisfiable declarations — required contradicting x-rules

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.

A capping rule wrapped in andOf is caught too — and only andOf

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, so ruleList(orOf(exactlyOneOf(&A::a, &A::b), engaged(&A::c))) generates.
  • Under not, the contradiction inverts into a requirement. With a and b both required, ruleList(notOf(exactlyOneOf(&A::a, &A::b))) asks for not exactly one of them engaged — which engaging both, precisely what required already 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::optional members. detail::isStdOptional keeps them out of required on sight, so a rule over std::optional fields can never reach the contradiction — with or without this check. The reachable case is an EmptyCapableField (a Quantity, a Choice, 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 fields and required is 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.

Server-side: the same list, evaluated in the dispatcher

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.

Renderer fallback

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" means defer, not block

"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.

Two evaluators, one corpus

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.cpp drives every row through allRulesSatisfied;
  • src/qt/forms/tests/tst_DynamicFormRuleCorpus.qml drives the same rows through a real DynamicForm.

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:

  1. every corpus schema equals the current schemaJson<A>() byte for byte;
  2. every kind detail::ruleKindName names appears somewhere in the corpus, so a seventeenth rule kind cannot join the vocabulary without rows;
  3. 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.

Computed fields

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 derivation fn(const A&) -> ValueOfDst. Dst and Inputs... 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 more computed(...) declarations into a detail::ComputeList<...> value assigned to static constexpr auto computedFields. The framework detects it via the detail::HasComputedFields<A> concept, mirroring detail::HasOptionalFields<A>/HasFormRules<A>.
  • recomputeAll<A>(action) is the single evaluator: it walks A::computedFields and, for each entry, overwrites the destination member with fn(action) — or, if any declared input is unengaged (hasValue() == false, for an input satisfying EmptyCapableField; 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 a Quantity destination the result is converted to the destination's own type and rounded to its declared precision (Quantity::atDeclaredPrecision()), so the stored value matches x-decimalPlaces regardless of what declared precision fn'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.
  • fn must 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's execute, not a computed field.

Schema emission

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.

Where the value is authoritative

recomputeAll runs at three call sites, all authoritative:

  1. ActionExecuteRegistry::registerAction's executor (the client-bridge JSON dispatch path behind BridgeHandler::executeJson, bridge.md).
  2. Bridge::executeVia's localOp (the in-process execution path LocalBackend uses for every execute<Action>()/executeJson call, bridge.md).
  3. ActionDispatcher::registerAction's runner (the server-side execution path RemoteServer uses for SimulatedRemoteBackend and 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.

Per-instance constraints — values that live in data

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:

  • Quantity fields only, matching checkAction. 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 double quotient of the bound's {num,den}, exactly like the compiled minimum/maximum beside it. The exact comparison is checkValue's, against a Rational; as everywhere else in the renderer, the live gate is an approximation and the model is the floor.

Support traits and helpers

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.

API reference

schemaJson<A>()

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).

allRequiredEngaged<A>()

Signature Returns
template <typename A> bool allRequiredEngaged(A const&) true when every required empty-capable field is engaged.

allFieldBoundsSatisfied<A>()

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.

EmptyCapableField<T> concept

Signature Checks
template <typename T> concept EmptyCapableField const T& has a noexcept .hasValue() returning convertible-to-bool.

Choice<T, OptionsAction, ValueField, LabelField, DependsOn...> and FixedString<N>

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.

Cross-field rules

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.

computed<Dst, Inputs...>() / computeList() / recomputeAll<A>()

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.

Design decisions

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.

Failure modes

Nested aggregates (recursive, cycle-safe)

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 $defs entry, $ref'd from every property, when the nested type is used two or more times anywhere in the schema. The shared $defs entry 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/$defs at 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.

Nesting depth in practice

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.

What DynamicForm does with a nested aggregate

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:

  1. A $ref cycle neither loops, hangs nor crashes the renderer. resolveRef (src/qt/forms/qml/DynamicForm.qml) follows a $ref exactly one level and merges the $def under the property; it never recurses, so a cycle is not a loop. The TreeNode/SelfAction schema quoted above builds a two-field form.
  2. An object-typed member becomes one plain text field. For that schema the renderer produces field_id and field_root, both TextFields, and no control for TreeNode's own name/children.
  3. An acyclic nested aggregate is flattened identically. An inlined address object with street/city members yields field_address and nothing for street or city. The cycle is not what stops the renderer — nesting is.
  4. 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 What ready claims): while the payload would have to carry one, ready is false, previewLine is empty, submitIfValid is never called, and unrepresentableReason names 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.

Scope: flat actions only (form layout, computed fields, and rules)

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>().

Total schema failure yields an empty string

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.

Limitations

Security / trust boundary

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). Enforces ActionValidator<Action>::ready after decoding and before invoking the handler (see bridge.md): an action that fails validate() is rejected with an error, never executed. It also retags Quantity fields to their declared precision (below).
  • Bridge::executeVia's localOp (LocalBackend, reached by any hand-built Action passed to BridgeHandler<Model>::execute<Action>() directly). Enforces the same ready() check before Model::execute, rejecting via onError with a morph::model::ValidationError (see bridge.md).
  • RemoteServer / ActionDispatcher (the server-side wire path, remote mode). ActionDispatcher::registerAction's runner reconciles declared Quantity precision and enforces ActionValidator<Action>::ready before Model::execute runs, throwing morph::model::ValidationError (a std::runtime_error subclass caught by RemoteServer's strand and turned into an err reply) when it returns false (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.

Advertised precision is enforced on dispatch

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.

Pre-decode wire validation — checkQuantityBounds

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.

Sum types not in the forms palette — multi-field encoding by design

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).

Per-instance variation is limited to key values

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.

One cached schema per type — no localisation

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.

Load-bearing assumptions about glaze's schema shape

The generator is coupled to concrete details of glaze's schema output shape, not just its public API:

  • A top-level properties object. mergeSchemaExtras indexes dom["properties"][name] directly. If glaze stopped emitting a properties object at the root (or nested it), the merge would create the wrong structure.
  • $defs numeric-bound preservation via generic_u64. The DOM is parsed in u64 number mode specifically because $defs carries int64/uint64 bounds 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-order is the index I from glz::reflect<A>::keys, and it is trusted to equal source declaration order. If glaze's reflection reordered keys, x-order would misdescribe the layout while still looking well-formed.
  • A glz::enumerated enum class as a oneOf of consts. 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 bare enum array 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.

Cross-references

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.

Out of scope

  • 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 — choices metadata tells the client which action to call, but the forms module does not invoke it.
  • morph::time::Timestamp definition — it is only consumed here via EmptyCapableField.