Part of the sweep tracked in #518. Finding F26.
Summary
Two separate gaps in the same subsystem, both reachable by wrapping a rule in one combinator.
(a) Unsatisfiable-form detection stops at the first compound node. rejectUnsatisfiableRules (forms.hpp:2373-2381) skips any node without a fields key:
auto const kindEntry = node->find("kind");
auto const fieldsEntry = node->find("fields");
if (kindEntry == node->end() || fieldsEntry == node->end()) {
continue;
}
And/Or/Not emit conditions/condition, never fields (forms.hpp:1507-1514, 1555-1562, 1601-1606), so the loop continues past them without descending.
(b) A presentation rule is accepted as a nested condition. andOf/orOf/notOf (forms.hpp:1525-1529, 1573-1577, 1614-1617) constrain their operands only to "has test(const A&) const noexcept". VisibleWhen::test and ReadonlyWhen::test return true unconditionally by design (forms.hpp:1394-1397, 1448-1451), so they satisfy the shape and are accepted.
Verification status
Reproduced. Revision: master @ 4017228d. Compiled against the repo's pinned glaze.
(a):
Direct (ruleList(exactlyOneOf(&a,&b))) -> THROWS UnsatisfiableFormError
Wrapped (ruleList(andOf(exactlyOneOf(&a,&b), engaged(&c)))) -> NO THROW, ships
Wrapped is genuinely dead: required is [a,b,c] while the rule caps a+b at exactly one, so allRequiredEngaged && allRulesSatisfied can never both hold.
(b) — andOf(visibleWhen(...), notEngaged(&a)) compiles and emits:
"when":{"kind":"and","conditions":[
{"kind":"visibleWhen","fields":["a"],"when":{"kind":"engaged","fields":["a"]}},
{"kind":"notEngaged","fields":["a"]}]}
In C++ the visibleWhen operand contributes a constant true, so the and collapses to its sibling. In the renderer, a when tree containing kind: "visibleWhen" hits no case in the condition vocabulary.
Not verified: I did not confirm the renderer's exact behaviour on the unknown condition kind (ignore vs. treat-as-false); either way client and server disagree.
Why these are defects and not design choices
For (a): the check's existence is what authors will trust. The identical contradiction is a hard build failure in one spelling and a silently unsubmittable form in the other — which is worse than not checking at all. forms.hpp:2342-2354 draws the tolerate/refuse line on consequence, deliberately and well; this is a gap in the implementation of that policy, not in the policy.
For (b): docs/spec/forms/forms.md:1744 ("Two evaluators, one corpus") states the invariant this breaks.
Suggested fix
(a) In rejectUnsatisfiableRules, when a node has no fields but has conditions (array) or condition (object), recurse into them with the same requiredNames. ~10 lines, no template changes.
(b) Give condition nodes a marker (static constexpr bool isCondition = true, or a Condition concept) and constrain andOf/orOf/notOf/requiredWhen/visibleWhen/readonlyWhen's when parameter on it. VisibleWhen/ReadonlyWhen/RequiredWhen do not get the marker.
Two cheap diagnostics fixes while here
andOf recovers the action type from its first operand only (forms.hpp:1527) and never checks the rest. Measured: andOf(engaged(&C::x), engaged(&B::y)) produces 153 lines of spew, first error inside std::is_nothrow_invocable in <type_traits>, the user's formRules line mentioned only around line 140. A requires (std::same_as<ConditionActionType<Cond0>, ConditionActionType<Conds>> && ...) makes it one line at the call site.
equals(&A::quantityMember, "URGENT") produces 83 lines, first error at forms.hpp:1092 (no match for 'operator=='), with no mention of equals. Same fix shape.
andOf(x) with a single operand compiles, though forms.hpp:1488 documents "at least two". Add a static_assert.
What would change the verdict
- Close (a) if compound-node contradiction detection is declared out of scope — but then the direct spelling should stop throwing too, so the two agree.
- Regression tests: the two repros above. A test using only the direct spelling passes with or without the fix.
Part of the sweep tracked in #518. Finding F26.
Summary
Two separate gaps in the same subsystem, both reachable by wrapping a rule in one combinator.
(a) Unsatisfiable-form detection stops at the first compound node.
rejectUnsatisfiableRules(forms.hpp:2373-2381) skips any node without afieldskey:And/Or/Notemitconditions/condition, neverfields(forms.hpp:1507-1514,1555-1562,1601-1606), so the loopcontinues past them without descending.(b) A presentation rule is accepted as a nested condition.
andOf/orOf/notOf(forms.hpp:1525-1529,1573-1577,1614-1617) constrain their operands only to "hastest(const A&) const noexcept".VisibleWhen::testandReadonlyWhen::testreturntrueunconditionally by design (forms.hpp:1394-1397,1448-1451), so they satisfy the shape and are accepted.Verification status
Reproduced. Revision:
master@4017228d. Compiled against the repo's pinned glaze.(a):
Wrappedis genuinely dead:requiredis[a,b,c]while the rule capsa+bat exactly one, soallRequiredEngaged && allRulesSatisfiedcan never both hold.(b) —
andOf(visibleWhen(...), notEngaged(&a))compiles and emits:In C++ the
visibleWhenoperand contributes a constanttrue, so theandcollapses to its sibling. In the renderer, awhentree containingkind: "visibleWhen"hits no case in the condition vocabulary.Not verified: I did not confirm the renderer's exact behaviour on the unknown condition kind (ignore vs. treat-as-false); either way client and server disagree.
Why these are defects and not design choices
For (a): the check's existence is what authors will trust. The identical contradiction is a hard build failure in one spelling and a silently unsubmittable form in the other — which is worse than not checking at all.
forms.hpp:2342-2354draws the tolerate/refuse line on consequence, deliberately and well; this is a gap in the implementation of that policy, not in the policy.For (b):
docs/spec/forms/forms.md:1744("Two evaluators, one corpus") states the invariant this breaks.Suggested fix
(a) In
rejectUnsatisfiableRules, when a node has nofieldsbut hasconditions(array) orcondition(object), recurse into them with the samerequiredNames. ~10 lines, no template changes.(b) Give condition nodes a marker (
static constexpr bool isCondition = true, or aConditionconcept) and constrainandOf/orOf/notOf/requiredWhen/visibleWhen/readonlyWhen'swhenparameter on it.VisibleWhen/ReadonlyWhen/RequiredWhendo not get the marker.Two cheap diagnostics fixes while here
andOfrecovers the action type from its first operand only (forms.hpp:1527) and never checks the rest. Measured:andOf(engaged(&C::x), engaged(&B::y))produces 153 lines of spew, first error insidestd::is_nothrow_invocablein<type_traits>, the user'sformRulesline mentioned only around line 140. Arequires (std::same_as<ConditionActionType<Cond0>, ConditionActionType<Conds>> && ...)makes it one line at the call site.equals(&A::quantityMember, "URGENT")produces 83 lines, first error atforms.hpp:1092(no match for 'operator=='), with no mention ofequals. Same fix shape.andOf(x)with a single operand compiles, thoughforms.hpp:1488documents "at least two". Add astatic_assert.What would change the verdict