Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
Aria Error Model

This document is the framework's authoritative reference for the error protocol. Every observable error surface MUST emit aria::Error values per the contract below. Together with lifecycle.md and api-style.md, this file forms the framework's three-pillar contract document family. Every contract item is numbered E-N for citation in code and commit messages.

What a "best-in-class C++ MVVM framework" requires of its error model:

  1. Unified: every observable error surface uses the same value type (aria::Error); a user does not need to learn fresh semantics per surface.
  2. Locatable: every Error carries kind + source + an optional key. UI / logs / routing can dispatch without parsing message strings.
  3. Never silent: errors must be observable — either as a Property<std::optional<Error>> you can subscribe to, or as a thrown exception that the call site sees immediately.
  4. Cancellation is not an error: OperationCancelled MUST NOT appear on last_error-style observation surfaces; it is control flow, not an observation event.

1. ErrorKind taxonomy

E-1: eight-value enum, never reordered, append-only

Kind When it fires Typical source
UserError Caller passed something that obviously violates the API contract (null view-model, out-of-range, wrong type) "Navigator", "Container"
Validation Validator / FormValidator rule failed "Validator", "FormValidator"
AsyncFailure Async body threw a non-cancel, non-timeout exception "AsyncCommand", "AsyncResource"
Cancellation Coroutine exited via OperationCancelled (diagnostic surfaces onlylast_error never carries this kind) "AsyncCommand"
Timeout with_timeout deadline hit (Race / Fail modes both map here) "AsyncCommand", "with_timeout"
BindingFailure Reserved for a future explicit binding error surface; current binding failures are reported through aria::CallbackFailure / callback_failure_sink (see E-30) "BindingEngine", "<adapter>"
GraphCycle Reactive graph reached kMaxFlushRounds, CircularDependencyError promotion "Graph"
InvariantViolation The framework's own contract was breached (stress / fuzz reports only) "Validator", "Graph", ...

E-2: enum order is stable

ErrorKind numeric ordering is stable, never reordered, append-only. Reason: the enum crosses the ABI boundary (used as the value of Property<std::optional<Error>>); reordering would break dylib compatibility.

E-3: Severity is meaningful only for Validation

Severity::Warning is used only when kind == Validation: it denotes a soft advisory (should() rule). For every other kind the severity MUST be Severity::Error; framework code does not construct Warnings outside Validation.

UI rendering convention:

  • Any Severity::Warning → yellow banner.
  • Any Severity::Error with kind != Cancellation → red error.
  • kind == Cancellation → don't render (control flow, not an error).

2. aria::Error value type

struct Error {
ErrorKind kind; // primary routing field
Severity severity;
std::string message; // human-readable
std::string source; // subsystem tag
ValidationKey key; // meaningful only for Validation
std::exception_ptr inner; // optional escape hatch
};

E-10: Error satisfies PropertyValue

Error is copyable + EqualityComparable, so it can be the T of a Property<std::optional<Error>>.

E-11: inner does not participate in equality

exception_ptr is pointer identity, useless for value equality. The Property write-equality gate (L-21) compares kind / severity / message / source / key. Consequence: writing the same logical error twice does NOT re-notify observers.

E-12: every Error must have a source

Factory functions (Error::async_failure / cancellation / timeout / user_error / graph_cycle / validation / validation_warning) all take a source_tag parameter (with sensible defaults). Constructing an Error with an empty source and feeding it to an observation surface is forbidden; empty source is reserved for unit-test internals.

E-13: from_exception is a degraded mapping

Error::from_exception(exception_ptr, source) recognises only standard library exceptions:

Exception Maps to
std::invalid_argument UserError
std::out_of_range UserError
Other std::exception AsyncFailure
Unknown AsyncFailure("unknown error")

Every non-null input retains its original exception_ptr in inner.

Aria's own sentinel exceptions are NOT recognised inside from_exception — that would force error.hpp to back-include async/, reactive/ and break the layering. Each error surface (classify_async_exception / a future Graph::handle_cycle / ...) catches its sentinels first, calls a precise factory, and only lets the residue flow into from_exception.


3. Per-surface protocol

E-20: AsyncCommand — rich + string twin observation surfaces

AsyncCommand exposes:

  • last_error: Property<std::optional<aria::Error>> — primary, rich payload.
  • last_error_message: Property<std::string> — string projection, UI-friendly.
  • last_result: Property<std::optional<R>> — only when R != void.

Contract:

  1. Every execute() start (the 0→1 inflight edge) clears both (nullopt / "").
  2. Async body threw OperationCancelled → both untouched (NOT recorded as an error).
  3. Async body threw TimeoutError → writes Error::timeout("AsyncCommand"); message is the original e.what().
  4. Async body threw anything else → writes Error::from_exception(ex, "AsyncCommand").
  5. last_error_message and last_error->message stay in sync; UI that only needs the string binds last_error_message; UI that needs kind or routing-actions binds last_error.
  6. The Property write-equality gate guarantees: writing the same error twice does NOT re-notify.

E-21: AsyncResource — same protocol as AsyncCommand

AsyncResource<T> exposes:

  • error: Property<std::optional<aria::Error>>
  • error_message: Property<std::string>
  • data: Property<std::optional<T>>
  • is_loading: Property<bool>

Contract:

  1. Every do_fetch_ start clears both error properties.
  2. SWR (stale-while-revalidate): on failure data is NOT cleared — the previous successful result stays so the UI does not blink to empty.
  3. kind mapping is identical to E-20.
  4. Cancellation does NOT surface on the error face.

E-22: Validator / FormValidator — the Error list inside ValidationState

ValidationState.errors: vector<Error> and .warnings: vector<Error>, each with kind = Validation and a populated key.

Contract:

  1. Every Error's key.field_path = the field_path supplied to the Validator's ctor; key.rule_id is either explicitly provided via must(..., ..., rule_id) or auto-generated as "rule_<N>".
  2. source is always "Validator" or "FormValidator" (cross-field rule).
  3. severity = Error lands in errors; severity = Warning lands in warnings.
  4. ValidationState.first_error() returns optional<Error> (NOT a string).
  5. FormValidator.first_error: Property<string> and first_error_full: Property<optional<Error>> — twin projections; contract is identical to E-20.
  6. When a cross-field rule fails: key.field_path == "", key.rule_id defaults to "form_rule_<N>" or whatever explicit rule_id was provided. This Error takes priority in first_error_full over any field-level error.

E-23: Graph cycle — CircularDependencyError exception → Error::graph_cycle()

The reactive graph's cycle detector still throws (CircularDependencyError's API stays stable, since it fires on construction- / set-time synchronous paths and the caller needs to know immediately).

But synchronous-path catchers (e.g. inside AsyncCommand's set chain) SHOULD wrap the exception into Error::graph_cycle(e.what(), current_exception()) and write it to the surface. classify_async_exception does NOT recognise CircularDependencyError today — that is a current gap, but since cycles are synchronous graph errors they should not normally arise inside an async body. The P0-ε fuzzer MUST verify this boundary.

E-24: Navigator — synchronous parameter validation still throws std::invalid_argument

Navigator::push(nullptr) still throws std::invalid_argument.

Rationale: Navigator is invoked synchronously by user code; a parameter error must be reported to the caller immediately or it gets deferred until the first stack-top access. throw is the elegant "never silent" form for this scenario.

If the caller invokes Navigator from inside an AsyncCommand body without catching it, classify_async_exception (per E-13) maps it to UserError via Error::from_exception — the error is still observable.

E-25: Container / DI — same protocol as Navigator

Container::resolve<I>() throws std::runtime_error when I was never registered. Semantics are identical to E-24: resolution happens synchronously in user code, so a missing registration is reported to the caller immediately rather than deferred.

There is no std::invalid_argument path — the container accepts no user-supplied values it could reject, only type keys. (std::any_cast inside resolve can in principle raise std::bad_any_cast if the same type_index is registered from two DSOs with incompatible types, but that is a build-configuration fault rather than a documented API outcome.)

E-26: BindingEngine — no Property-shaped error surface today

BindingEngine does NOT expose a Property-shaped error observation surface. Reasons:

  1. View-adapter setters' contract is "idempotently update the native widget" — they SHOULD NOT throw. If one does, that is an adapter implementation bug.
  2. The View → VM path is user input; input validity SHOULD be handled by the ViewModel's Validator, not by BindingEngine reporting "input parse failed".
  3. If an adapter setter does throw, the path is: BindingEngine::dispatch_to_view_'s lambda runs in a slot dispatched by the dispatcher → the dispatcher implementation (SimpleDispatcher::pump / MainThreadExecutor::drain / MainThreadExecutor::run_one) catches at its try/catch boundary and forwards via aria::report_callback_failure(category, std::current_exception()) → the host application bridges to aria::Logger::error(category, what) via aria::runtime::install_default_diagnostics(). The whole chain is noexcept; nothing reaches std::terminate.
  4. dispatch_to_view_ itself has no try/catch — its job is "post + liveness check + trace"; exceptions sink down to the dispatcher boundary where the unified callback_boundary handles them. Its trace funnels through two non-template helpers, trace_drop_(platform) / trace_emit_(platform), in all three branches (Direct / SmartMarshal-on-thread / dispatcher.post), avoiding duplication and template bloat (see the "BindingEngine trace helper" section in api-style.md).

ErrorKind::BindingFailure is reserved: if we ever introduce an "explicit binding error surface" (e.g. a typed converter failing on VM→View), it activates then. No code in the current release emits ErrorKind::BindingFailure. Synchronous callback / converter / view-model boundary failures instead route through aria::report_callback_failure(...) and the host-installed callback_failure_sink; adapter setters remain expected to be idempotent and non-throwing.

E-27: Logger contract — never throws, never silently drops a message

aria::runtime::Logger::log is the framework's lowest-level observability primitive. It must be callable from any call site, including framework-internal noexcept boundaries (worker / drain / pump / abi trampoline). The contract:

  1. Never throws: Logger::log is not declared noexcept in the signature (we keep it non-noexcept for compatibility with Sink = std::function<...>), but the implementation never propagates an exception out. If a user-installed sink throws:
    • std::exception → fall back to stderr: [<LEVEL>][<category>] <message> (sink threw: <e.what()>).
    • Non-std exceptions (throw 42 etc.) → fall back to stderr: (sink threw: non-std exception).
    • Both paths are tied off via try/catch inside Logger::log; nothing escapes back to the caller (especially the framework-internal noexcept boundaries).
  2. Never silent: when the sink throws, the original log record still goes to the stderr fallback with a "sink threw" tag — keeping observability while not losing the message.
  3. Pairs with callback_boundary: when the host registers aria::Logger as the callback_boundary sink (the default install_default_diagnostics() wires this up), even if the Logger sink itself throws further, the callback_boundary's own try/catch + stderr fallback (see L-31.6) is a second line of defence — two layers of protection, no path leads to std::terminate.
  4. Test coverage: two contract tests in runtime/tests/test_logger.cpp ("throwing sink does not propagate (std::exception)" / "throwing sink does not propagate (non-std exception)") prevent regressions.

4. Call stack vs observation surface: when to throw, when to set

Scenario Path Choice
Construction-time argument validation failure Sync throw std::invalid_argument
Property::set triggered cycle Sync throw CircularDependencyError
AsyncCommand body throws Async (worker, then back to ui) set last_error and do NOT rethrow to the user (run_to_result_ folds the exception into AsyncCommandResult::{Cancelled, Failed}; execute() reports through error_sink_; co_execute() lets the caller branch on r.failed())
AsyncResource fetch failure as above set error
Validator rule failure Sync inside the graph set state.errors
Cross-field rule failure Sync inside the graph set first_error_full

Core principle:

  • Synchronous, caller is right there expecting a return value → throw.
  • Asynchronous, caller has long since left → set Property (the caller observes via subscription).
  • Cancellation → neither throw nor set (it's control flow).

5. Anti-patterns

# Anti-pattern Consequence Correct approach
AE1 last_error.set(Error{kind=Cancellation, ...}) UI displays "user cancellation" as an error Cancellation does NOT go on the error surface; classify_async_exception already handles this correctly (folds into AsyncCommandResult::Cancelled, doesn't write last_error)
AE2 Adapter setter throws std::runtime_error Exception is swallowed by the dispatcher, UI fails silently Don't throw from a setter; setters MUST be idempotent and non-throwing
AE3 Constructing an Error with empty source and feeding it to a surface The router can't tell which subsystem this came from Every factory takes source_tag — the caller MUST provide it
AE4 last_error.get() == "kaboom" to discriminate errors String compare is fragile; messages may be localised last_error.get()->kind == ErrorKind::AsyncFailure instead
AE5 Letting OperationCancelled go through Error::from_exception Becomes AsyncFailure("operation cancelled") on the observation surface Catch OperationCancelled first in the catch chain and rethrow before from_exception sees it
AE6 Navigator::push(nullptr) setting a Property instead of throwing Caller proceeds; problem hidden Synchronous paths MUST throw

6. Cross-document references

  • lifecycle.md L-21 spells out that Property writes are equality-gated; E-11 here depends on that to guarantee "rewriting the same error doesn't re-notify".
  • api-style.md S-31 requires exception what() to follow the What/Where/How recipe; E-13 / E-20 here bring that recipe to the observation surface via Error.kind / source / message.
  • api-style.md S-40 "no deprecated aliases": when this contract landed, every caller reading last_error as a string was broken outright and migrated to last_error_message or last_error->message.

7. Verification targets

The error-model invariants below are pinned by fuzzers in modules/core/fuzz/, all built into the aria_fuzz binary and run by the fuzz_tests ctest target:

Invariant fuzzer source
AsyncCommand cancellation never surfaces on the error face (E-20 clause 2) fuzz: E-20 cancellation never surfaces on AsyncCommand's error face fuzz_async_command_cancellation_no_error.cpp
Repeated set of the same Error does not notify observers (E-11 + L-21) E-11 fuzz: equal Errors do not re-notify, unequal ones always do fuzz_error_property_equality_gate.cpp
Validator errors' key.field_path always equals the Validator's path (E-22 clause 1) fuzz: Validator field_path is an invariant of the validator fuzz_validator_field_path.cpp
Error::from_exception mapping is stable across std exception types (E-13) E-13 fuzz: from_exception mapping is stable across std exception types fuzz_error_from_exception_table.cpp

Notes on what these actually pin, since the shape is not obvious from the invariant statement alone:

  • Cancellation is a negative contract, and E-20 clauses 1 and 2 compose into something stronger than either alone: because clause 1 clears the error face on every execute() entry, a cancelled invocation must leave it clean — not showing whatever the previous failure left behind. The fuzzer walks random succeed / fail / timeout / cancel sequences against one long-lived command and additionally pins the fail -> cancel ordering directly.
  • Equality gate: the reference model spells out E-11's field list (kind / severity / message / source / key) without calling operator==(Error, Error). Using the library's own operator would make the assertion a tautology — mutate the operator and the model adopts the same mutation. The fuzzer also cross-checks that operator== agrees with the field list, which is the assertion that fires if inner ever starts participating in equality.
  • field_path: E-22 clause 1 is an invariant of the validator, not of the rule, and it has four production sites (rule / warning / end_pending(vector<string>) / end_pending(vector<Error>)). The last one is asymmetric on purpose: the framework backfills an empty field_path but must not clobber one the caller set. Note also that async_errors_ is re-merged on every revalidation rather than consumed once, so a caller-set path keeps surviving later source writes.
  • from_exception: every non-null input retains its original exception_ptr, including UserError mappings and non-standard throws. Classification and display text may degrade; the cause remains available.

Iteration count defaults to 50k per fuzzer; set ARIA_FUZZ_ITERS=1000000 (optionally with ARIA_FUZZ_SEED) for nightly / pre-release runs.


8. Document governance

Every error-model protocol change MUST flow as: doc change → code change → test change. Any new ErrorKind MUST first be registered in the E-1 table here.