Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/spec/forms/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ Satisfied by:
`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`](../util/tagged.md)).
- Any user type that exposes `bool hasValue() const noexcept`.

A **non**-empty-capable field (plain `int64_t`, `std::string`, …) is always
Expand Down
9 changes: 6 additions & 3 deletions docs/spec/util/quantity_type.md
Original file line number Diff line number Diff line change
Expand Up @@ -719,8 +719,11 @@ readability).
| `fromDouble` | `static Quantity fromDouble(double raw)` | Tags the leaf at `declaredPrecision()`; **never empty from a finite value** (empty only when `raw` is non-finite / doesn't fit). |
| `fromOptional` | `static Quantity fromOptional(std::optional<Rational>)` | Empty in → empty out, preserving the declared-precision arg. |

> The declared-decimals template argument `Dec` is constrained to `[1, kMaxDecimalPlaces]` (18).
> Values outside that range cause a `static_assert` failure at compile time.
> The declared-decimals template argument `Dec` is constrained to `[0, kMaxDecimalPlaces]` (18).
> Values outside that range cause a `static_assert` failure at compile time. `Dec == 0` is a
> legal, first-class declared precision — it is what a zero-decimal currency (JPY, KRW) or a
> plain integer count declares, and it formats and parses with no fractional digit or decimal
> point at all (see *How a value prints*).

### `Quantity<U, Dec>` — access, precision, provenance

Expand All @@ -730,7 +733,7 @@ readability).
| `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`). |
| `withDecimalPlaces(p)` | `Quantity withDecimalPlaces(DecimalPlaces) const` | Retags actual precision (silently clamped to `[1, kMaxDecimalPlaces]`); no-op on empty. Value unchanged. |
| `withDecimalPlaces(p)` | `Quantity withDecimalPlaces(DecimalPlaces) const` | Retags actual precision (silently clamped to `[0, kMaxDecimalPlaces]`); no-op on empty. Value unchanged. |
| `atDeclaredPrecision()` | `Quantity atDeclaredPrecision() const` | Retags actual precision to the declared one; no-op on empty. |
| `named(label)` | `Quantity named(std::string label) const` | Returns a same-unit quantity marked as the symbol `label`; builds a fresh history node (no-op returning empty on empty, or with tracing off). |
| `equation()` | `std::vector<std::string> equation() const` | The worked formula as print-ready lines (see *Provenance*). Single-element (the formatted value) when empty or tracing off. |
Expand Down
32 changes: 16 additions & 16 deletions docs/spec/util/rational.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,16 @@ Every public operation that produces a `Rational` restores:
- `denominator > 0` (strictly positive — never zero, never negative)
- `gcd(|numerator|, denominator) == 1`
- canonical zero is `0/1`
- `1 <= decimalPlaces.value <= kMaxDecimalPlaces`
- `0 <= decimalPlaces.value <= kMaxDecimalPlaces`

All sign lives in the numerator.

**No default precision.** Every call site states the precision it intends, e.g.
`Rational{Numerator{1}, Denominator{3}, DecimalPlaces{9}}`. Precision is capped at `kMaxDecimalPlaces`
(18, the largest `k` for which `10^k` fits in `int64_t`); out-of-range values
assert in debug and clamp into `[1, kMaxDecimalPlaces]` in release.
assert in debug and clamp into `[0, kMaxDecimalPlaces]` in release. Zero decimal places is a
legal, first-class precision — a whole-number value (a zero-decimal currency such as JPY/KRW, or
a plain integer count) declares `DecimalPlaces{0}` and carries no fractional digit at all.

The struct never throws. Operations that may fail (zero divisor, non-finite
floating-point input, overflow during decimal scaling) return
Expand Down Expand Up @@ -281,7 +283,7 @@ through `setWire`.
|---|---|---|
| `numerator` | `int64_t` | Carries the sign of the rational value. |
| `denominator` | `int64_t` | Strictly positive. Never zero, never negative. |
| `decimalPlaces` | `DecimalPlaces` | `1 <= value <= kMaxDecimalPlaces`. |
| `decimalPlaces` | `DecimalPlaces` | `0 <= value <= kMaxDecimalPlaces`. |

### `Rational` — accessors

Expand Down Expand Up @@ -344,7 +346,7 @@ expected<Rational, RationalError> operator+(Left const&, Right const&) noexcept;
|---|---|
| [`quantity_type.md`](quantity_type.md) | `Rational` is the **runtime substrate** for `Quantity`. A `Quantity`'s declared precision and the forms layer's `x-decimalPlaces` schema annotation both resolve, at runtime, to a `Rational`'s `DecimalPlaces` tag — the `dp` value carried on the wire and propagated through arithmetic here is exactly the precision a `Quantity` declares. The overflow envelope and `INT64_MIN` hazards documented above therefore bound `Quantity` too. |
| [`forms.md`](../forms/forms.md) | The form generator reads `x-decimalPlaces` (and the `Rational` wire shape `{"num","den","dp"}`) to build precision-aware numeric inputs; a form value is a `Rational` under the hood, so its display uses `toDouble`/formatting and its exact value uses the wire codec. |
| [`security.md`](../security.md) | `setWire` performs the untrusted-wire **clamping** (`den == 0 → 1`, out-of-range `dp` → `[1, 18]`, `INT64_MIN` → `-INT64_MAX`). This is the boundary defence that keeps a hostile payload from reaching the UB-prone negation/overflow sites; see the clamping semantics discussion there. |
| [`security.md`](../security.md) | `setWire` performs the untrusted-wire **clamping** (`den == 0 → 1`, out-of-range `dp` → `[0, 18]`, `INT64_MIN` → `-INT64_MAX`). This is the boundary defence that keeps a hostile payload from reaching the UB-prone negation/overflow sites; see the clamping semantics discussion there. |
| [`datetime.md`](datetime.md) | Contrast case for wire-decode policy: the `DateTime` codec is **strict** (rejects malformed input) whereas `Rational::setWire` is **lenient/clamping** (silently repairs it). See [Limitations](#limitations) for why the difference matters. |

## Limitations
Expand All @@ -360,18 +362,16 @@ expected<Rational, RationalError> operator+(Left const&, Right const&) noexcept;
`dp` → magnitude table.
- **`setWire` clamps hostile input rather than rejecting it.** `den == 0`
becomes `1`, an `INT64_MIN` component becomes `-INT64_MAX`, an out-of-range
`dp` is pulled into `[1, 18]`. The invariants are always restored, but a
*corrupt amount silently becomes a specific wrong number* — e.g. a payload
meant to carry `x/0` lands as `x/1`, a completely different value, with no
error surfaced. This is deliberate (a `Rational` never propagates UB from the
wire) but it trades detectability for robustness. It is the opposite policy
from the strict `DateTime` codec, which rejects malformed input outright
(cross-ref [`datetime.md`](datetime.md)); a caller that needs "reject, don't
guess" semantics for amounts must validate before decode.
- **`DecimalPlaces` has a floor of 1.** The invariant is `1 <= value <= 18`, so
precision **0 is unrepresentable**. This excludes zero-decimal currencies
(JPY, KRW) and plain integer counts from being tagged with their true
precision — they must borrow `dp = 1` and carry a spurious fractional digit.
`dp` is pulled into `[0, 18]` (only the upper bound can ever fire, since `dp`
is unsigned and `0` is itself a legal precision). The invariants are always
restored, but a *corrupt amount silently becomes a specific wrong number* —
e.g. a payload meant to carry `x/0` lands as `x/1`, a completely different
value, with no error surfaced. This is deliberate (a `Rational` never
propagates UB from the wire) but it trades detectability for robustness. It
is the opposite policy from the strict `DateTime` codec, which rejects
malformed input outright (cross-ref [`datetime.md`](datetime.md)); a caller
that needs "reject, don't guess" semantics for amounts must validate before
decode.
- **`==` and `<=>` ignore precision, so equality is not substitutability.**
Comparison is purely value-based on the canonical `(numerator, denominator)`
pair. Two `Rational`s can therefore satisfy `a == b` while
Expand Down
153 changes: 153 additions & 0 deletions docs/spec/util/tagged.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# The `Tagged` type — design

`morph::util::Tagged<T, Tag>` is an opaque, type-safe newtype for protocol
scalars: a pagination cursor, an event id, a job id, a bearer token — anything
that is, on the wire, a bare `T` (usually `std::string` or an integer) but
must not be interchangeable at the C++ level with a different scalar that
happens to share the same underlying type.

`Tagged<std::string, "UserId">` and `Tagged<std::string, "AccountId">` carry
the identical wire representation but are distinct C++ types: a function
expecting one does not accept the other, and neither is constructible from
the other. Every application built on the framework otherwise hand-rolls the
same wrapper shape per protocol scalar; `Tagged` is the framework-level,
day-one primitive for it.

## Contents

- [Identity via the tag](#identity-via-the-tag)
- [Construction and access](#construction-and-access)
- [Comparison](#comparison)
- [Wire and schema](#wire-and-schema)
- [Forms palette — `hasValue()`](#forms-palette--hasvalue)
- [Design decisions](#design-decisions)
- [Cross-references](#cross-references)
- [Limitations](#limitations)
- [Out of scope](#out-of-scope)

## Identity via the tag

`Tag` is a `morph::detail::FixedString` non-type template parameter — the
same structural string type `morph::forms::Choice` and
`morph::units::NamedQuantity` use (`morph::detail::FixedString`,
`include/morph/detail/fixed_string.hpp`; there is one definition, not
several look-alikes). Because the tag lives in the type itself:

- Two `Tagged<T, "X">` spelled identically in different translation units are
one and the same type — no companion `enum` or registry is needed to keep
tags distinct or to make ODR happy.
- `Tagged<T, "X">` and `Tagged<T, "Y">` are unrelated types even though they
share `T`: neither converts to nor is constructible from the other
(`Tagged`'s only converting constructor takes a `T`, not another `Tagged`
specialization), so passing a `UserId` where an `AccountId` is expected is
a compile error, not a runtime data bug.
- `tag()` returns the tag text (`Tag.view()`) for introspection or logging.

## Construction and access

| Member | Signature | Notes |
|---|---|---|
| default ctor | `constexpr Tagged() noexcept(...)` | Wraps a default-constructed `T{}`. Not "empty" in the `Quantity`/`Choice` sense — see *Forms palette* below. |
| value ctor | `constexpr explicit Tagged(T wrapped) noexcept(...)` | Wraps @p wrapped. `explicit`, so a bare `T` never silently becomes a `Tagged` at a call site — the wrapping is always visible in the code. |
| `get()` | `const T& get() const noexcept` | Named read access. |
| `operator*()` | `const T& operator*() const noexcept` | Unchecked access, mirroring `Quantity`'s and `Choice`'s `operator*` for a consistent shape across the framework's newtype family — there is nothing to check here (the value always exists), but the same spelling reads uniformly next to `*quantity` / `*choice`. |
| `tag()` | `static constexpr std::string_view tag() noexcept` | The compile-time tag text. |

## Comparison

`operator==` is defaulted (present only when `T` is
`std::equality_comparable`), and `operator<=>` is defaulted (present only
when `T` is `std::three_way_comparable`) — both compare on the wrapped value
alone. Comparison is only ever defined between two `Tagged` of the **same**
`T` and `Tag`; there is no cross-tag `==`/`<=>` overload, so `userId ==
accountId` fails to compile even when both wrap `std::string` — the same
protection the type provides at construction extends to comparison.

## Wire and schema

On the morph JSON wire a `Tagged<T, Tag>` is **exactly its `T` payload** —
`glz::meta<Tagged<T, Tag>>` maps the instance to the single member `value`,
so it reads and writes byte-for-byte like a bare `T` (a `Tagged<std::string,
"UserId">` writes as a JSON string, a `Tagged<std::int64_t, "EventSeq">` as a
JSON number). The tag never appears in the wire payload.

The schema tells a different, narrower story: `to_json_schema<Tagged<T,
Tag>>` delegates to `to_json_schema<T>` for the shape (`type`, numeric
bounds, and so on are identical to `T`'s own schema) but `glz::meta`'s `name`
is fixed to the tag text, so the schema's `title` carries the tag
(`"UserId"`, `"EventSeq"`) rather than `T`'s own type name. Two `Tagged<T,
Tag1>` / `Tagged<T, Tag2>` therefore produce structurally identical but
distinctly titled schema entries — a client-side codegen tool can still tell
them apart even though the wire shape is shared.

## Forms palette — `hasValue()`

`Tagged` is a **required** scalar, not an optionally-empty field like
`Quantity` / `Choice` / `Timestamp`: it always holds a `T` (default- or
value-constructed), never `std::nullopt`. Its `hasValue()` therefore always
returns `true` (`noexcept`), which satisfies `morph::forms::EmptyCapableField`
— the concept the forms rule vocabulary, `allRequiredEngaged`, and
`recomputeOne`'s per-input engagement check all key on (see
[forms.md](../forms/forms.md)).

Satisfying `EmptyCapableField` (rather than simply not satisfying it, as a
plain unwrapped scalar member would) means `Tagged` participates in the
palette the same way `Quantity`/`Choice`/`Timestamp` do — a rule can name a
`Tagged` field and `isEngaged()` resolves it via `hasValue()`, always `true`
— instead of being silently treated as "no empty state, so always counted
present" through the *other*, no-concept branch those helpers also support.
The visible behavior is the same (always engaged) either way; the difference
is that `Tagged` opts in explicitly rather than falling through the fallback
path, which matters if a future palette helper ever distinguishes "declares
no empty state" from "declares an empty state that never triggers."

A `Tagged` field is therefore always **required** wherever
`morph::forms::schemaJson` derives requiredness, exactly like a plain
non-optional scalar member — wrap the underlying `T` in `std::optional` at
the call site (`std::optional<Tagged<T, Tag>>`) if a genuinely optional
tagged scalar is needed; `Tagged` itself does not grow a second, orthogonal
empty state.

## Design decisions

| Decision | Choice | Why |
|---|---|---|
| Identity mechanism | **NTTP string tag (`FixedString`), not a phantom enum/type parameter** | The tag lives in the type without a companion declaration; reuses the same structural string type `Choice`/`NamedQuantity` already rely on, so there is exactly one such type in the codebase. |
| Emptiness | **Always engaged (`hasValue()` → `true`)** | `Tagged` wraps a required protocol scalar (an id, a cursor, a token) — these are not normally optional fields. A genuinely optional tagged scalar composes via `std::optional<Tagged<...>>` instead of `Tagged` inventing a second empty state. |
| Construction | **`explicit` value constructor, no converting constructor from another `Tagged`** | A bare `T` never silently becomes a `Tagged` (visibility at the call site), and one tag's value never silently becomes another tag's value (no accidental cross-tag construction). |
| Wire | **Transparent — `glz::meta` maps straight to the payload** | Matches the existing newtype family (`Quantity`, `Choice`, `Timestamp` all reduce to their payload on the wire); the tag is a compile-time-only distinction, invisible to any wire consumer. |
| Schema `title` | **Set to the tag, not to `T`'s type name** | Lets client-side codegen distinguish `UserId` from `AccountId` in the generated schema even though both compile down to the same wire shape. |

## Cross-references

- **[`../detail/fixed_string.hpp`](../../../include/morph/detail/fixed_string.hpp)**
— the shared NTTP string type; see its own doc comment for the ODR/identity
argument this spec relies on.
- **[`choice.md`](../forms/choice.md)** — `Choice<T, OptionsAction, ...>`
shares the same `FixedString` mechanism and the same "wraps to a payload
member on the wire" shape, but is optionally empty where `Tagged` is always
engaged.
- **[`forms.md`](../forms/forms.md)** — `EmptyCapableField`, `isEngaged`, and
`allRequiredEngaged` — the palette `Tagged`'s `hasValue()` plugs into.

## Limitations

- **No arithmetic, no formatting, no hashing.** `Tagged` deliberately offers
only construction, access, and same-type comparison — it does not forward
`T`'s other operations (arithmetic, `std::format` support,
`std::hash`). An application that needs one of those either unwraps via
`get()`/`operator*()` at the point of use or extends `Tagged` locally; nothing
here forwards automatically.
- **No implicit conversion to `T`.** Reaching the wrapped value always goes
through `get()` or `operator*()`; there is no `operator T()` that would let
a `Tagged` quietly decay back to a bare scalar (and, from there, back into
another tag's slot through an implicit `T` conversion).

## Out of scope

- Runtime-checked tag identity (e.g. a UUID namespace) — the tag is a
compile-time distinction only, erased entirely from the wire and from RTTI.
- A phantom-type variant (`Tagged<T, struct UserIdTag>`) — the chosen
mechanism is the `FixedString` NTTP already used elsewhere in the codebase,
for consistency; nothing prevents an application from rolling its own
phantom-type wrapper alongside `Tagged` if it prefers that idiom.
4 changes: 2 additions & 2 deletions include/morph/util/quantity.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -547,8 +547,8 @@ struct Context {
template <auto U, std::uint32_t DeclaredDecimals>
requires UnitEnum<decltype(U)>
struct Quantity {
static_assert(DeclaredDecimals >= 1 && DeclaredDecimals <= math::kMaxDecimalPlaces,
"declared decimals must be within [1, kMaxDecimalPlaces]");
static_assert(DeclaredDecimals <= math::kMaxDecimalPlaces,
"declared decimals must be within [0, kMaxDecimalPlaces]");

/// @brief The payload; `std::nullopt` means "not entered / not measured".
std::optional<math::Rational> payload;
Expand Down
Loading
Loading