Skip to content
Merged
11 changes: 10 additions & 1 deletion docs/spec/core/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ the action's validator** (see below), calls `execute<Action>`, and serializes
the result back to JSON. Throws `std::runtime_error` if the action was never
registered.

Between decode and dispatch the registry executor applies three
Between decode and dispatch the registry executor applies four
normalisations, in order, so the request/reply path matches the schema and
the reactive `set<>` path rather than trusting the raw wire body:

Expand All @@ -356,6 +356,15 @@ the reactive `set<>` path rather than trusting the raw wire body:
the schema's advertised `x-decimalPlaces` instead of whatever runtime `dp` the
client sent. It is a no-op for actions with no `Quantity` members and for
actions whose type glaze cannot reflect. See [forms.md](../forms/forms.md).
- **Pre-decode wire validation.** `morph::forms::enforceQuantityBounds` rejects
a `Quantity` field whose engaged value falls outside its unit's declared
bounds (`UnitTraits<E>::bounds`), throwing `QuantityDecodeError` — caught by
the same catch block as every other decode/validation failure on this path.
Runs after precision reconciliation and before the validator check below, so
an out-of-bounds value never reaches business-rule validation or the
handler. No-op for actions with no `Quantity` members, or whose units
declare no `bounds()`. See [forms.md](../forms/forms.md), "Pre-decode wire
validation".
- **Computed-field recompute.** `morph::forms::recomputeAll` overwrites every
`A::computedFields` destination from its declared inputs, discarding
whatever value the wire carried for it — a computed field is never trusted
Expand Down
23 changes: 16 additions & 7 deletions docs/spec/core/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,15 @@ struct ValidationError : std::runtime_error {
(`morph::forms::reconcileDeclaredPrecision`) before the `ready()` check, so a
hand-built wire payload's `Quantity` values match the schema's advertised
`x-decimalPlaces` the same way the client bridge dispatch path already
normalises them (see [forms.md](../forms/forms.md)). `Bridge::executeVia`'s
`localOp` does not reconcile precision — that path never decodes JSON, so
there is no wire `dp` to reconcile against.
normalises them (see [forms.md](../forms/forms.md)). Immediately after
reconciliation, `morph::forms::enforceQuantityBounds` rejects any `Quantity`
field whose engaged value falls outside its unit's declared bounds
(`UnitTraits<E>::bounds`), throwing `QuantityDecodeError` before the `ready()`
check — a no-op for actions with no `Quantity` members, or whose units
declare no `bounds()` (see [forms.md](../forms/forms.md), "Pre-decode wire
validation"). `Bridge::executeVia`'s `localOp` does not reconcile precision or
enforce bounds — that path never decodes JSON, so there is no wire `dp` or
wire value to check against.

`ValidationError` derives from `std::runtime_error`, so it is caught by
existing generic `catch (const std::exception&)` handling on both paths
Expand Down Expand Up @@ -386,12 +392,15 @@ class ActionDispatcher {
```

- `registerAction` registers a runner that deserialises, reconciles any
`Quantity` fields to their declared precision, overwrites any declared
`Quantity` fields to their declared precision, rejects any `Quantity` field
outside its unit's declared bounds (`morph::forms::enforceQuantityBounds`,
throwing `QuantityDecodeError`; a no-op for actions with no `Quantity`
members or whose units declare no `bounds()`), overwrites any declared
computed fields from their inputs (`morph::forms::recomputeAll`,
[forms.md](../forms/forms.md) — a no-op for actions with no
`computedFields`; runs after precision reconciliation and before the
validator check, so the validator sees the authoritative computed value),
enforces `ActionValidator<Action>::ready(action)` (throwing `ValidationError`
`computedFields`; runs after precision reconciliation and bounds
enforcement and before the validator check, so the validator sees the
authoritative computed value), enforces `ActionValidator<Action>::ready(action)` (throwing `ValidationError`
on `false`, before `Model::execute` runs), then calls `Model::execute(action)`
inside a `try`/`catch (const std::exception&)`: on success it serialises the
result and records a `LogEntry` with `outcome = Outcome::Succeeded` (when
Expand Down
186 changes: 181 additions & 5 deletions docs/spec/forms/forms.md

Large diffs are not rendered by default.

65 changes: 65 additions & 0 deletions docs/spec/util/quantity_type.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,68 @@ selector lists; the selector deliberately offers only the one-hop neighbours the
as alternatives, but `g` lists only `kg` — even though `g → t` converts by
chaining through `kg`.)

### Pre-decode wire validation — declared bounds

`setWire` (above) is deliberately permissive: it silently clamps a hostile
`dp` and normalises a non-canonical numerator/denominator rather than
rejecting them, so decoding itself never throws. That leaves a gap for a
value that decodes *successfully* but is still physically or contractually
impossible for its unit — a percentage above 100, a mass below zero — with no
seam to reject it before an action's own `validate()` (a business-rule check,
not a decode-level one) runs.

`Quantity<U, Dec>::withinDeclaredBounds() -> bool` closes that gap, driven by
an **optional** customisation point:

```cpp
template <>
struct morph::units::UnitTraits<Unit> {
static constexpr UnitMeta meta(Unit u) noexcept { /* ... */ }

// Optional: declares [min, max] for units that have a physical/contract range.
static constexpr morph::units::QuantityBounds bounds(Unit u) noexcept {
switch (u) {
case Unit::percent:
return {.min = Rational{0, DecimalPlaces{1}}, .max = Rational{100, DecimalPlaces{1}}};
default:
return {.min = Rational{Numerator{std::numeric_limits<std::int64_t>::min()}, Denominator{1}, DecimalPlaces{1}},
.max = Rational{Numerator{std::numeric_limits<std::int64_t>::max()}, Denominator{1}, DecimalPlaces{1}}};
}
}
};
```

- **`QuantityBounds { Rational min; Rational max; }`** — an inclusive range.
- **`HasUnitBounds<E>`** — `true` when `UnitTraits<E>` declares `bounds(E)`.
A unit enum with no `bounds()` declares none: every value its precision
allows is accepted, byte-for-byte the same as before this feature existed
— this is an opt-in check, not a new default restriction.
- **`withinDeclaredBounds()`** — `true` when the payload is empty (an
unengaged field has nothing to be out of bounds), when the unit declares no
`bounds()`, or when the engaged value satisfies `min <= value <= max`.
Comparison is on the exact `Rational` (via `operator<=>`), never a lossy
`double`.

**The forms-layer seam.** `morph::forms::checkQuantityBounds<A>(action)`
(`forms.hpp`) walks every reflected `Quantity` member of an action the same
way `reconcileDeclaredPrecision` does, and returns the wire name of the first
member failing `withinDeclaredBounds()` (or `std::nullopt`).
`morph::forms::enforceQuantityBounds<A>(action)` throws
`morph::forms::QuantityDecodeError` naming that field. Both dispatch runners
that decode wire JSON into an action — `ActionDispatcher::registerAction`'s
server-side runner and `ActionExecuteRegistry::registerAction`'s client
bridge runner (`registry.hpp`/`bridge.hpp`) — call `enforceQuantityBounds`
immediately after `reconcileDeclaredPrecision` and before `recomputeAll`/the
`ActionValidator::ready` check, 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 stay distinct
so a caller (or a test) can tell "the wire payload itself was impossible"
from "the decoded action failed its own business rule". The in-process
`localOp` execution path (`bridge.hpp`) is unaffected, for the same reason it
skips `reconcileDeclaredPrecision`: no JSON decode happens there, so there is
nothing to validate at that seam — a `Quantity` constructed directly by
calling code carries whatever bounds the caller gave it.

## Unit conversion — `UnitRelation` and `convert`

Arithmetic works only on values of the *same* unit, but the same physical
Expand Down Expand Up @@ -697,6 +759,8 @@ readability).
| `UnitEnum<E>` | concept | Satisfied by an enum with a `UnitTraits<E>::meta`. Constrains `Quantity`. |
| `isQuantity<T>` | `inline constexpr bool` variable template | Compile-time test: `true` when `T` is a `Quantity<...>`. |
| `operator*`, `operator/` (on `E`) | `consteval` | Application-supplied unit algebra deducing cross-dimension result units; an unsupported combination fails to compile. |
| `QuantityBounds { min, max }` | struct | Inclusive `Rational` range returned by the optional `UnitTraits<E>::bounds(E)` customisation point — the pre-decode wire validation seam (see "Pre-decode wire validation" above). |
| `HasUnitBounds<E>` | concept | `true` when `UnitTraits<E>` declares `bounds(E)`. A unit enum without it declares no bounds: every value its precision allows is accepted. |

### `Quantity<U, Dec>` — compile-time members

Expand Down Expand Up @@ -730,6 +794,7 @@ readability).
| Member | Signature | Notes |
|---|---|---|
| `hasValue()` | `constexpr bool hasValue() const noexcept` | Engaged? No implicit `bool` conversion. |
| `withinDeclaredBounds()` | `constexpr bool withinDeclaredBounds() const noexcept` | The pre-decode wire validation seam: `true` when empty, when the unit declares no `bounds()`, or when the engaged value satisfies `min <= value <= max` (exact `Rational` comparison). See "Pre-decode wire validation" above. |
| `value()` | `const std::optional<Rational>& value() const noexcept` | The payload; pattern-match or `->` it. |
| `value_or(fallback)` | `Rational value_or(Rational const&) const` | Payload if engaged, else the fallback. |
| `operator*` | `const Rational& operator*() const` | Unchecked access to the engaged value (UB when empty, like `std::optional`). |
Expand Down
12 changes: 0 additions & 12 deletions examples/forms/gui_qml/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,6 @@ target_link_libraries(lab_forms_demo_module PUBLIC morph::morph morph::qt_forms
target_compile_features(lab_forms_demo_module PUBLIC cxx_std_23)
apply_bigobj(lab_forms_demo_module)

# FormsController.cpp instantiates schema/rule templates over every demo
# action type -- the same pattern examples/forms/CMakeLists.txt's
# morph_forms_demo target hit (see #80). Under MSVC Debug (no /Og folding,
# full /Zi debug info) this pushes the .obj's COFF section count past the
# 32-bit SN_LOFF format's limit, aborting with C1128 ("number of sections
# exceeded object file format limit"). /bigobj switches to the extended
# section-count format; harmless on Release and on other compilers, so it is
# unconditional for this target.
target_compile_options(lab_forms_demo_module PRIVATE
$<$<CXX_COMPILER_ID:MSVC>:/bigobj>
)

qt_add_executable(morph_forms_qml main.cpp)
target_link_libraries(morph_forms_qml PRIVATE lab_forms_demo_moduleplugin Qt6::Quick)

Expand Down
9 changes: 9 additions & 0 deletions include/morph/core/bridge.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -1708,6 +1708,15 @@ inline void ActionExecuteRegistry::registerAction(std::string_view modelId, std:
// silently keeping whatever runtime `dp` the client sent. No-op for
// actions with no Quantity members. See docs/spec/forms.md.
::morph::forms::reconcileDeclaredPrecision(action);
// Pre-decode wire validation seam: reject a Quantity field whose
// engaged value falls outside its unit's declared bounds
// (UnitTraits<E>::bounds), before the validator/business-rule
// check below. No-op for actions with no Quantity members, or
// whose units declare no bounds(). See docs/spec/forms/forms.md,
// "Pre-decode wire validation". Thrown as QuantityDecodeError,
// caught by the same catch block as every other decode/validation
// failure on this path.
::morph::forms::enforceQuantityBounds(action);
// Overwrite any computed fields from their declared inputs -- a
// computed field is never trusted from the client, on any path.
// No-op for actions with no computedFields. See docs/spec/forms/forms.md.
Expand Down
7 changes: 7 additions & 0 deletions include/morph/core/registry.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,13 @@ class ActionDispatcher {
// No-op for actions with no Quantity members. See
// docs/spec/forms/forms.md.
::morph::forms::reconcileDeclaredPrecision(action);
// Pre-decode wire validation seam: reject a Quantity field whose
// engaged value falls outside its unit's declared bounds
// (UnitTraits<E>::bounds), before the action's own validate()
// (a business-rule check) ever runs. No-op for actions with no
// Quantity members, or whose units declare no bounds(). See
// docs/spec/forms/forms.md, "Pre-decode wire validation".
::morph::forms::enforceQuantityBounds(action);
// Overwrite any computed fields from their declared inputs. This is
// the true server-side execution site for every remote and Qt
// WebSocket topology (RemoteServer -> ActionDispatcher::dispatch) --
Expand Down
Loading
Loading