Part of the sweep tracked in #518. Finding F25.
Summary
choice.hpp:162 and widget_hints.hpp:138 give every instantiation the same schema name:
static constexpr std::string_view name = "Choice"; // for EVERY instantiation
static constexpr std::string_view name = "Ranged"; // for EVERY Min/Max/Step
glaze keys $defs by name_v<T> and populates a definition only once (glaze/json/schema.hpp:1069-1072: auto& def = defs[name_v<val_t>]; if (!def.type) { … }). The second distinct instantiation is silently skipped and $refs the first one's definition.
Verification status
Reproduced. Revision: master @ 4017228d. Compiled against the repo's pinned glaze.
For struct BoolFirst { Choice<bool,"YesNo"> flag; Choice<std::int64_t,"ListSamples"> sampleId; }:
"sampleId":{"$ref":"#/$defs/Choice", ...}
"$defs":{"Choice":{"type":["boolean","null"]}}
An int64 picklist described as a boolean. Also verified:
Choice<std::string,"ListCodes"> next to Choice<std::int64_t,…> → the string field gets {"type":["integer","null"],"minimum":-9223372036854775808,…}.
Ranged<0.0,1.0,0.1> next to Ranged<0,100> → {"type":["integer","null"],"minimum":-2147483648,…} while the property correctly carries "x-step":0.1. Every legal value of the double slider fails the type it was served under.
Not verified: I did not drive the QML renderer end-to-end to observe the wrong control on screen; the control-selection path is traced by reading (below).
Why this is not benign
docs/spec/forms/choice.md:150-161 and widget_hints.md:128-138 call this benign — "renderers that submit the raw nullable value observe no problem". That is false for morph's own shipped renderer:
src/qt/forms/qml/DynamicForm.qml:229 (resolveRef) merges the $def over the property.
:460 sets isBoolean: types.indexOf("boolean") !== -1.
:1069 draws a checkbox.
It is also false for any generic validating client: the document emits additionalProperties: false and a standard required array, i.e. it is presented as validatable.
This is the same failure mode that the glaze_enum_t static_assert at forms.hpp:2064 was added to prevent (morph#392), re-entering through a different door.
Suggested fix
Make name unique per instantiation, exactly as Quantity already does (util/quantity.hpp:1209 composes from UnitTraits<…>::meta(U).id):
Choice — compose from the FixedString NTTPs, e.g. "Choice_" + OptionsAction + "_" + ValueField.
Ranged — compose from a constexpr rendering of Min/Max/Step.
Both are compile-time deterministic and compiler-independent (important: they must not derive from glz::name_v, for the reason payload_schema.hpp:66-70 gives).
This changes $defs keys, so it is a wire-shape version bump — but the current behaviour is a wrong schema, not a compatible one.
If the name cannot change, the minimum fix is a static_assert rejecting two Choice/Ranged instantiations with different payload types in one action.
What would change the verdict
- Close it if the renderer contract is changed so that
$def type is never consulted — but DynamicForm.qml:460 consults it today.
- Regression test: generate a schema for an action with two differently-typed
Choice members and assert both $defs entries exist with the right types. A single-Choice action passes with or without the fix.
Part of the sweep tracked in #518. Finding F25.
Summary
choice.hpp:162andwidget_hints.hpp:138give every instantiation the same schema name:glaze keys
$defsbyname_v<T>and populates a definition only once (glaze/json/schema.hpp:1069-1072:auto& def = defs[name_v<val_t>]; if (!def.type) { … }). The second distinct instantiation is silently skipped and$refs the first one's definition.Verification status
Reproduced. Revision:
master@4017228d. Compiled against the repo's pinned glaze.For
struct BoolFirst { Choice<bool,"YesNo"> flag; Choice<std::int64_t,"ListSamples"> sampleId; }:An int64 picklist described as a boolean. Also verified:
Choice<std::string,"ListCodes">next toChoice<std::int64_t,…>→ the string field gets{"type":["integer","null"],"minimum":-9223372036854775808,…}.Ranged<0.0,1.0,0.1>next toRanged<0,100>→{"type":["integer","null"],"minimum":-2147483648,…}while the property correctly carries"x-step":0.1. Every legal value of the double slider fails the type it was served under.Not verified: I did not drive the QML renderer end-to-end to observe the wrong control on screen; the control-selection path is traced by reading (below).
Why this is not benign
docs/spec/forms/choice.md:150-161andwidget_hints.md:128-138call this benign — "renderers that submit the raw nullable value observe no problem". That is false for morph's own shipped renderer:src/qt/forms/qml/DynamicForm.qml:229(resolveRef) merges the$defover the property.:460setsisBoolean: types.indexOf("boolean") !== -1.:1069draws a checkbox.It is also false for any generic validating client: the document emits
additionalProperties: falseand a standardrequiredarray, i.e. it is presented as validatable.This is the same failure mode that the
glaze_enum_tstatic_assertatforms.hpp:2064was added to prevent (morph#392), re-entering through a different door.Suggested fix
Make
nameunique per instantiation, exactly asQuantityalready does (util/quantity.hpp:1209composes fromUnitTraits<…>::meta(U).id):Choice— compose from theFixedStringNTTPs, e.g."Choice_" + OptionsAction + "_" + ValueField.Ranged— compose from a constexpr rendering ofMin/Max/Step.Both are compile-time deterministic and compiler-independent (important: they must not derive from
glz::name_v, for the reasonpayload_schema.hpp:66-70gives).This changes
$defskeys, so it is a wire-shape version bump — but the current behaviour is a wrong schema, not a compatible one.If the name cannot change, the minimum fix is a
static_assertrejecting twoChoice/Rangedinstantiations with different payload types in one action.What would change the verdict
$deftype is never consulted — butDynamicForm.qml:460consults it today.Choicemembers and assert both$defsentries exist with the right types. A single-Choiceaction passes with or without the fix.