morph is a typed, asynchronous bridge between a GUI thread and business-object models. Models may live in-process (local mode) or in a remote server process (remote mode). The GUI code is identical in both cases — only the backend implementation changes.
The framework is header-only (C++23, namespace morph), depends on Glaze for JSON reflection, and optionally integrates with Qt 6 via a separate target.
Design specs. This document is the cross-cutting map. The authoritative, per-subsystem reference lives in
docs/spec/— one file per public type or subsystem, capturing invariants, API surface, and the reasoning behind each design. Consult the matching spec before changing a public type: e.g.docs/spec/security.md(authenticated sessions and the trust model),docs/spec/VERSIONING.md(the semantic-versioning / deprecation-window commitment),docs/spec/core/completion.md,docs/spec/core/executor.md,docs/spec/core/bridge.md,docs/spec/journal/journal.md,docs/spec/offline/offline.md,docs/spec/session/session.md,docs/spec/core/wire.md. Where this document and a spec disagree, the spec wins.
The public surface is split per topic so callers always know whether a name is part of the stable API or an implementation detail.
| Namespace | Purpose | Public symbols |
|---|---|---|
morph:: |
Macros only at this level | BRIDGE_REGISTER_MODEL, BRIDGE_REGISTER_ACTION, BRIDGE_REGISTER_VALIDATOR (at file scope, but specialise morph::model::* templates) |
morph::version |
Release version constants | kMajor, kMinor, kPatch, kString (see docs/spec/VERSIONING.md) |
morph::log |
Configurable logging | LogLevel, setLogger, setLogLevel, getLogLevel, logDebug, logInfo, logWarn, logError |
morph::exec |
Executor primitives | IExecutor, ThreadPoolExecutor, MainThreadExecutor |
morph::async |
Async result handle | Completion<T> |
morph::model |
Model & action traits | ModelTraits<>, ActionTraits<>, ActionValidator<>, ActionLogPolicy<>, Loggable |
morph::backend |
Pluggable backends | LocalBackend, RemoteServer, SimulatedRemoteBackend |
morph::bridge |
Bridge between handler and backend | Bridge, BridgeHandler<M> |
morph::offline |
Connectivity + replay | NetworkMonitor, NetworkMonitorConfig, IOfflineQueue, QueueItem, InMemoryOfflineQueue, SyncWorker, SyncResult, ReconnectCoordinator, ReconnectOutcome, ReconnectCoordinatorConfig |
morph::session |
Per-call session context + authentication | Context, IAuthorizer, AllowAllAuthorizer, allowAllAuthorizer, current; authenticated sessions (session_auth.hpp): SessionToken, TokenIssuer, TokenVerifier, SigningAuthorizer, MacFunction, hmacSha256, AuthError |
morph::journal |
Ordered, replayable action log (issue #3) | LogEntry, IActionLog, InMemoryActionLog, FileActionLog, SessionLog, replay(), toJson/fromJson, setActionLog, defaultActionLog, ScopedActionLog |
morph::math |
Exact numeric values for actions | Rational, DecimalPlaces, RationalError, kMaxDecimalPlaces, abs/ceil/floor/trunc |
morph::units |
Unit-tagged, optionally-empty values | Quantity<U>, UnitMeta, UnitTraits<E> (app-specialised), UnitAlternative<E>, HasUnitAlternatives, UnitEnum, isQuantity |
morph::time |
UTC timestamps for actions | DateTime, Timestamp |
morph::forms |
JSON-Schema generation for auto-built GUIs | schemaJson<A>(), allRequiredEngaged(), Choice<T, ...>, FixedString, isChoice, EmptyCapableField |
morph::qt |
Qt integration (built only when MORPH_BUILD_QT=ON) |
QtExecutor, QtWebSocketBackend, QtWebSocketServer |
Every nested detail namespace under those topics holds implementation symbols. These do appear in some public signatures (e.g. Bridge's constructor takes unique_ptr<backend::detail::IBackend>), but callers never type a detail name directly — std::make_unique<morph::backend::LocalBackend>(...) converts implicitly.
┌─────────────────────────────────────────────────────────────────┐
│ Application / GUI │
│ morph::bridge::BridgeHandler<Model> ← typed user-facing API │
├─────────────────────────────────────────────────────────────────┤
│ Public API (morph::bridge / morph::async) │
│ Bridge · BridgeHandler<M> · Completion<T> │
├─────────────────────────────────────────────────────────────────┤
│ Backend abstraction (morph::backend + morph::backend::detail) │
│ IBackend · LocalBackend · SimulatedRemoteBackend │
│ RemoteServer · QtWebSocketBackend · QtWebSocketServer │
├─────────────────────────────────────────────────────────────────┤
│ Registry & type erasure (morph::model + morph::model::detail) │
│ ActionDispatcher · ModelRegistryFactory · ModelTraits · … │
├─────────────────────────────────────────────────────────────────┤
│ Internal async core │
│ IExecutor · ThreadPoolExecutor · MainThreadExecutor │
│ (executor.hpp) │
│ StrandExecutor · ModelId (strand.hpp) │
│ CompletionState<T> (completion.hpp) │
├─────────────────────────────────────────────────────────────────┤
│ Cross-cutting │
│ LogLevel · setLogger · log helpers (logger.hpp) │
└─────────────────────────────────────────────────────────────────┘
Local mode — model lives in the same process:
GUI thread
└─ bridge::BridgeHandler<M>::execute(action)
└─ bridge::Bridge::executeVia<M, A>
└─ backend::LocalBackend::execute
└─ StrandExecutor → worker thread → Model::execute(action)
└─ async::Completion<T>::then callback → GUI executor
Simulated-remote mode — model lives behind an in-process RemoteServer (used in tests):
GUI thread
└─ bridge::BridgeHandler<M>::execute(action)
└─ bridge::Bridge::executeVia<M, A>
└─ backend::SimulatedRemoteBackend::execute
└─ serialize action → backend::RemoteServer::handle (JSON wire envelope)
└─ ActionDispatcher → StrandExecutor → Model::execute
└─ serialize result → Completion<T>::then → GUI executor
Qt WebSocket mode — model lives in a separate process. Three classes with distinct responsibilities collaborate here: qt::QtWebSocketBackend is the network client (owns the QWebSocket, correlates replies to pending Completions, handles reconnect); qt::QtWebSocketServer is the network server (pure transport — receives text frames and forwards them verbatim to RemoteServer::handle(), sends the reply back); backend::RemoteServer is the protocol + dispatch layer (parses envelopes, authorizes, routes to models) and is transport-agnostic — the same class also serves the simulated-remote topology above.
GUI thread (Qt process) Server process
qt::QtWebSocketServer (transport)
└─ bridge::BridgeHandler<M>::execute(action)
└─ bridge::Bridge::executeVia<M, A>
└─ qt::QtWebSocketBackend::execute (network client)
└─ assign callId, send JSON ──► qt::QtWebSocketServer::handle
└─ backend::RemoteServer::handle (JSON wire envelope)
└─ ActionDispatcher → StrandExecutor → Model::execute
◄── JSON reply (ok|callId|result) ──────────────────────────────
└─ resolve pending Completion
└─ async::Completion<T>::then callback → qt::QtExecutor → GUI thread
The GUI sees the same morph::async::Completion<T> in all three modes.
Offline / sync mode — how the morph::offline primitives compose. These four classes do not form a backend of their own; they wrap whichever backend is active and drive Bridge::switchBackend + replay when connectivity flaps. The framework ships the primitives and the sequencing; the application wires them together (the probe, the queue's persistence, and the replay function are all caller-supplied):
morph::offline::NetworkMonitor (detects connectivity; probe is caller-supplied)
│ onOffline ──► app calls Bridge::switchBackend(local fallback)
│ app/enqueuer writes pending actions into IOfflineQueue
│ (InMemoryOfflineQueue ships with the framework;
│ a durable SQL-backed queue is the app's to provide)
│ onOnline ──► morph::offline::ReconnectCoordinator (sequences:)
│ 1. tryReconnect() → probe the primary backend
│ 2. activatePrimary() → Bridge::switchBackend(primary)
│ 3. bindContext() → rebind handlers
│ 4. replay() → morph::offline::SyncWorker::run()
│ drains IOfflineQueue via a
│ caller-supplied ReplayFunction
NetworkMonitor fires the transitions; ReconnectCoordinator owns the ordering and retry loop; SyncWorker owns the queue drain; IOfflineQueue owns the pending writes. Each is usable independently of the others.
RemoteServer::handle accepts JSON envelopes (morph::wire::Envelope). The kind
field is the discriminator:
kind |
Direction | Required fields | Meaning |
|---|---|---|---|
"register" |
client → server | typeId, contextKey (optional) |
Register a model instance; server replies ok with modelId |
"deregister" |
client → server | modelId |
Destroy model instance; server replies ok |
"execute" |
client → server | callId, modelId, modelType, actionType, body, session (optional) |
Dispatch an action |
"ok" |
server → client | callId, plus body (execute result) or modelId (register reply) |
Success |
"err" |
server → client | callId (echoed), message |
Failure |
All envelopes round-trip through Glaze JSON, so the protocol is self-describing, escaping-safe, and easy to extend (add a field, ignore unknowns).
The session field is a morph::session::Context carrying a verified bearer
token alongside the (untrusted) client-asserted principal.
The server runs every incoming execute envelope through its configured
IAuthorizer; a false return causes the server to reply with
err|unauthorized (callId echoed). The default authorizer permits everything.
When a verifying authorizer (SigningAuthorizer) is installed, the server also
calls IAuthorizer::authenticate and, when it yields a principal from a valid
token, overwrites Context::principal with it before dispatch — so the
verified principal is authoritative and session::current()->principal read
inside a model is the authenticated identity, not the client's claim. See
"Authenticated sessions" below and docs/spec/security.md.
RemoteServer::handleInline is the synchronous variant used for control
messages (register/deregister) — e.g. when a BridgeHandler is constructed
from inside an action handler running on the worker pool. It rejects
execute up front with an err reply, because an execute reply is produced
asynchronously on the strand, after the synchronous call has already returned
and destroyed the reply buffer the deferred callback would write into.
contextKey carries a registering instance's stable identity (e.g. an
account id) from HandlerBinding::contextKey across the wire, so a
server-side RemoteServer::LogProvider can attach an action log to the
instance it creates — see "Action log" below. Empty (the default) means no
identity; the field is ignored on every other envelope kind.
All concurrency runs through morph::exec::IExecutor::post(fn):
ThreadPoolExecutor— fixed N worker threads, MPMC queue. A task exception is caught and logged viamorph::log(std::exceptionwith itswhat(), anything else as "unknown exception"); it never propagates out of the worker or aborts sibling tasks.MainThreadExecutor— single-threaded queue withrunFor(timeout)drain; used in non-Qt tests to pump the "GUI" thread. It catches onlystd::exceptionfrom a task, logs it viamorph::log, and continues with the next task.QtExecutor— posts viaQMetaObject::invokeMethod(Qt::QueuedConnection); safe from any thread; drops silently if the target object is deleted.
morph::exec::detail::StrandExecutor (below) is where Model::execute() actually runs; like ThreadPoolExecutor, it catches a task exception (std::exception or unknown) and logs it via morph::log so a throw neither stalls the strand nor vanishes — the next queued task for that model still runs.
morph::exec::detail::StrandExecutor guarantees that all tasks for the same ModelId are serialised while tasks for different models are parallelised. Internally keeps one std::queue and a running flag per model; tasks are dispatched to the underlying IExecutor one at a time.
Move-only public result handle (morph::async::Completion<T>). Internally backed by morph::async::detail::CompletionState<T> (mutex-protected value + error + two callback slots). Key invariant: callbacks are always delivered via the IExecutor supplied at construction, so the GUI thread is never blocked and callbacks always fire on the right thread.
If a Completion is destroyed with an unhandled error (no .onError was attached), CompletionState::~CompletionState logs the exception through the configured logger.
morph::model::ModelTraits<M> and morph::model::ActionTraits<A> are specialised by registration macros. They provide:
- String type IDs for protocol routing — the string literals passed as the
NAMEargument toBRIDGE_REGISTER_MODEL(M, "MyModel")/BRIDGE_REGISTER_ACTION(M, A, "MyAction")in the.cppthat owns the model. These strings travel in the wire envelope'smodelType/actionTypefields and key the server's dispatch tables. - Glaze-based
toJson/fromJsonfor actions and results.
morph::model::detail::ActionDispatcher maps (modelTypeId, actionTypeId) → a runner lambda that downcasts IModelHolder, calls model.execute(action), and returns the result as JSON.
morph::model::detail::ModelRegistryFactory maps modelTypeId → a factory closure that constructs an IModelHolder.
When bridge::BridgeHandler<M> registers with the bridge::Bridge, the backend assigns it a ModelId (just a uint64_t). That ID is backend-local — if the backend is ever replaced, all existing IDs are meaningless.
The problem is that Bridge needs to be able to find every live BridgeHandler later (e.g. to re-register it on a new backend), but it cannot hold a strong reference to them — that would prevent BridgeHandler from being destroyed naturally at the end of its scope.
bridge::detail::HandlerBinding is the indirection that solves both constraints:
BridgeHandler<M> ──owns──► shared_ptr<HandlerBinding>
Bridge ──holds──► weak_ptr<HandlerBinding> (does NOT keep it alive)
BridgeHandler holds the shared_ptr, so the HandlerBinding lives exactly as long as the handler does. Bridge holds a weak_ptr, so it can observe whether the handler is still alive — but it cannot prevent its destruction.
RAII lifetime: When BridgeHandler goes out of scope, its destructor calls Bridge::deregisterHandler, which tells the backend to destroy the model instance and removes the stale weak_ptr from the Bridge's list. No manual cleanup is required from application code.
Destruction order is safe either way: the BridgeHandler also holds a weak_ptr<const void> liveness token published by the Bridge (Bridge::liveness(); the token is destroyed with the bridge). The destructor deregisters only if that token is still live — if the Bridge was destroyed first, the token has expired and the handler skips deregistration instead of dereferencing a dangling Bridge&. Destroying a bridge before its handlers is still discouraged, but is defined behaviour rather than a use-after-free.
atomic<uint64_t> currentId: Every call to executeVia reads binding->currentId to find out which backend-assigned ID to use. The value is an atomic so it can be updated during a backend switch without holding the bridge mutex for the duration of every execute call. A value of 0 is the sentinel for "not bound" — executeVia returns an immediate error in that case.
Backend switching: Bridge::switchBackend(newBackend) acquires the bridge mutex, re-registers every live HandlerBinding on the new backend, replaces the active backend, then calls notifyBackendChanged(). LocalBackend forwards this to every live model that implements IBackendChangedSink (detected at compile time via the BackendChangedNotifiable concept). SimulatedRemoteBackend is a no-op — its models live inside RemoteServer.
The switch is exception-safe and atomic (stage-all-then-commit): phase 1 registers every binding on the new backend into a staging list without touching any currentId; if any registration throws (a plausible remote/transport failure) the already-registered instances are rolled back with deregisterModel and the exception is rethrown, leaving the old backend and every currentId untouched — the switch either fully succeeds or is a no-op. Only after all registrations succeed does phase 2 publish the new ModelId values atomically into each binding->currentId and swap the backend in. The outgoing backend's pending completions are drained (cancelPending with BackendChangedError) outside the bridge mutex, so user callbacks never run while _mtx is held.
morph::log provides a global, mutex-protected, replaceable sink (std::function<void(LogLevel, std::string_view)>). LogLevel is a uint8_t-backed enum (debug < info < warn < error < off). All framework internals route through morph::log::detail::log(level, msg); call morph::log::setLogger at startup to redirect to spdlog, Qt logging, or a test spy.
morph::session carries a per-call Context (principal, verified token,
requestId, locale, and a free-form metadata bag) from the caller through the
bridge to the model — session::current() reads it inside execute() without
changing the model signature. The server-side authorization seam is
IAuthorizer (authorize gates each execute; authenticate returns the
verified principal to make authoritative); AllowAllAuthorizer /
allowAllAuthorizer() is the default and permits everything.
session_auth.hpp adds the opt-in authenticated variant: signed, stateless
bearer tokens (SessionToken claims, base64url(claims).base64url(mac)) minted
by TokenIssuer, verified by TokenVerifier, and enforced per request by
SigningAuthorizer. The MAC primitive is pluggable (MacFunction); a
self-contained, test-vector-verified hmacSha256 is the default reference
implementation. AuthError enumerates verification failures.
docs/spec/security.md is the authoritative spec for the trust model, the
authoritative-principal flow, and the limits of the reference crypto — this
section is only a map, not a substitute.
morph::offline::NetworkMonitor provides a background probe thread that tracks connectivity and fires callbacks on transitions. The framework supplies no probe implementation — the caller provides a bool() callable that returns true when the system is reachable (TCP connect, HTTP ping, custom check, etc.). This keeps the monitor fully portable across OS and transport.
State machine:
- Starts online (assumes connectivity until proven otherwise).
- Goes offline after
failureThresholdconsecutive probe failures → firesonOffline. - Returns online after
onlineThresholdconsecutive probe successes → firesonOnline. - Each transition fires its callback exactly once.
- Callbacks run on the probe thread and must not block.
Thread safety: isOnline() reads an std::atomic<bool> — safe from any thread. stop() is idempotent and safe to call multiple times or from any thread. The destructor calls stop(), so explicit shutdown is optional.
Intended integration with switchBackend:
morph::offline::NetworkMonitor monitor{
myTcpProbe,
[&] { bridge.switchBackend(std::make_unique<morph::backend::LocalBackend>(localPool)); },
[&] { bridge.switchBackend(std::make_unique<morph::backend::SimulatedRemoteBackend>(server)); }
};morph::offline::IOfflineQueue provides an interface over three operations:
| Method | Semantics |
|---|---|
enqueue(payload) |
Append an opaque string item; returns a stable uint64_t id |
drain() |
Return all pending items in enqueue order; does not remove them |
markDone(id) |
Remove item by id; no-op if unknown |
drain() returning items without removing them is deliberate — items survive a crash between drain() and markDone(). A SQL-backed implementation can persist items across process restarts by storing them in a table with a UNIQUE constraint on the payload.
InMemoryOfflineQueue implements the interface with a std::deque protected by a mutex. It does not deduplicate.
morph::journal records executed actions as an ordered, replayable log, distinct in purpose from IOfflineQueue above: IOfflineQueue holds pending writes awaiting retry and deletes them once delivered; the action log is a permanent audit/replay trail — entries are never removed by the framework.
IActionLog is the durable-sink interface (append, flush, entries), implemented by InMemoryActionLog and FileActionLog (append-only NDJSON, flush() fsyncs). Each LogEntry carries modelType, entityKey, actionType, payload/result JSON, principal, and a sink-assigned seq. FileActionLog::entries() tolerates a torn trailing line: a crash between append's write and the next flush can leave a truncated final line, so a malformed last line is logged and skipped rather than throwing — but a malformed line mid-file is genuine corruption and is re-thrown.
Set the sink once, in main() — every model uses it automatically. morph::journal::setActionLog(log) installs a process-wide default. ModelFactory::create<Model>() — the factory behind every ordinary model registration, local or remote — attaches that default to each new instance automatically (empty entityKey). No per-model, per-handler, or per-backend wiring is required; RemoteServer-owned instances get it exactly the same way, since they're constructed through the same factory. defaultActionLog() reads the current sink back; ScopedActionLog (RAII, mirrors morph::log::ScopedLoggerOverride) installs one temporarily and restores the previous one on scope exit — the tool tests use it to avoid leaking a sink across test cases.
Application code that needs a specific instance identity (e.g. per-account auditing) can still call IModelHolder::attachActionLog(log, contextKey) explicitly on that instance — an explicit call always overrides the default, and is the seam HandlerBinding::contextKey/RemoteServer::setLogProvider (below) build on for the remote case. Recording itself happens at the two call sites that are the only two places Model::execute() is ever invoked in the whole codebase:
| Site | Topology |
|---|---|
ActionDispatcher::registerAction's runner (registry.hpp) |
Every remote/Qt topology — RemoteServer owns the persistent IModelHolders and dispatches through here |
Bridge::executeVia's localOp (bridge.hpp) |
Local mode only — LocalBackend calls this directly; remote backends never invoke localOp at all |
Because these are mutually exclusive per topology, recording is automatically server-side wherever a client/server split exists, with no extra plumbing.
Loggable (morph::model::Loggable::{Yes,No}) is a strong-typed opt-out on the existing BRIDGE_REGISTER_ACTION macro (an optional 4th argument; no separate registration macro). Default is Yes — every action is recorded unless explicitly marked Loggable::No (typically pure queries like GetAccount/ListAccounts). Hand-written ActionTraits specialisations that predate this member (as used in several tests) are unaffected: morph::model::detail::actionLoggable<A>() defaults to Yes when the member is absent, via a HasLoggableFlag concept exactly like ActionValidator's HasValidate.
ActionLogPolicy<A>::coalesce (default false) decides whether repeated executions of the same action against the same entity should collapse to the latest occurrence at a checkpoint, or whether every occurrence is a distinct, permanent fact. This matters because a form driving one action per edit can fire the same action many times in a row — without coalescing, every keystroke-driven re-fire would become a permanent log entry. false is correct for anything resembling a business event (a deposit); true is for drafts/settings where only the final value matters.
SessionLog (journal.hpp) is where coalescing actually happens. It keeps full, uncoalesced history in memory (the raw material for undoLast()), and checkpoint(durableSink) reduces everything appended since the last checkpoint by (modelType, entityKey, actionType) — keeping only the latest entry where coalesce == true, every entry otherwise — before forwarding the reduced set to the real sink. undoLast() needs no inverse operations: it drops the most recent entry and calls journal::replay() over what remains, reusing the same ActionDispatcher/ModelRegistryFactory RemoteServer already relies on for dispatch. This is not a workaround — a model's entire state genuinely is "initial state plus its ordered actions replayed," so reconstructing it by replay is the direct statement of that fact, not a special case.
Replay/undo never records into the live default log. journal::replay() builds the reconstructed holder through ModelRegistryFactory::create, which (like every factory-built model) auto-attaches the process-wide default action log. Before replaying, replay() immediately detaches it (attachActionLog(nullptr, {})), so re-running the recorded actions does not re-record each one into the live sink — which would corrupt the very audit trail being read from. The suppression is scoped to the replay pass only: once replay finishes, the reconstructed instance becomes the live model, and any new action executed on it goes through the normal dispatch path (with the log attached) and is recorded as a new entry appended after the surviving entries — e.g. journal [A, B, C], undo C, execute D leaves [A, B, D].
Remote-mode per-instance identity (RemoteServer::setLogProvider) is the advanced escape hatch for when the global default isn't granular enough: RemoteServer owns the actual model instances behind any remote/simulated-remote client, so it is the only place able to attach a different log (or a specific entityKey) to a specific instance. HandlerBinding::contextKey (client-side) travels through the register wire envelope's contextKey field; if a LogProvider is installed, RemoteServer calls it with (modelType, contextKey) and attaches whatever IActionLog it returns (or nothing, if it returns nullptr or no contextKey was sent) before the instance ever executes an action — overriding whatever the global default would have attached.
Transactional outbox (opt-in): a model that also owns its own durable store can avoid the log and the store's committed state silently diverging by writing its own outbox row inside its own transaction, calling IModelHolder::setOutboxManaged(true) to suppress the automatic append, and draining that outbox through a journal::OutboxRelay into the real sink — see docs/spec/journal/journal.md's "Transactional outbox (opt-in)" section. examples/bank still demonstrates the un-opted-in two-independent-writes behavior this closes for models that adopt the pattern.
morph::offline::SyncWorker drains an IOfflineQueue on reconnect. The caller supplies a ReplayFunction (bool(const std::string& payload)) that knows how to process each item — the framework has no knowledge of what replay means (insert to DB, POST to API, etc.).
- Returns
true→markDonecalled, item removed. - Returns
falseor throws → item left in queue for the nextrun(). stop()signals the currentrun()to abort after the current item. Resets at the start of eachrun(), so it is one-shot.- Concurrent
run()calls are serialised by an internal mutex.
morph::offline::ReconnectCoordinator sequences the steps that must happen, in
order, when connectivity returns: tryReconnect() → activatePrimary() →
bindContext() → replay(). All side effects are injected via Deps
callables — the class contains only the retry loop, the strict ordering
guarantee (each step waits for the previous), and the abort checks
(shouldContinue() is polled before each attempt and again before replay). It
performs no I/O and owns no thread; onOnline() / onOffline() run
synchronously on the calling thread and are mutually serialised by an internal
mutex (mirroring SyncWorker::run()). onOnline() returns a ReconnectOutcome
(Reconnected / GaveUp after maxAttempts / Aborted); retry tuning lives in
ReconnectCoordinatorConfig (maxAttempts, retryDelay). tryReconnect and
shouldContinue throwing are treated as a failed attempt / "do not continue"
respectively.
Conflict resolution during offline-to-online sync belongs entirely in the model. The framework's role is to fire onBackendChanged() on the new model instance and then step back. How the model handles that notification is its own business.
What the framework guarantees:
onBackendChanged()fires exactly once perswitchBackend()call.- It fires on the new backend's model instance (not the old one).
- It fires after all handlers are re-registered —
execute()calls issued from withinonBackendChanged()will reach the new backend. - Each
switchBackend()creates a fresh model instance via the registered factory. If the factory captures dependencies by reference, the new instance shares the same queue, resolver, and domain services as the old one.
| Component | Guarantee |
|---|---|
Model::execute |
Never called concurrently for the same ModelId (strand). |
Completion<T> / CompletionState<T> |
Fully mutex-protected; callbacks always marshal to the supplied executor. |
Bridge |
Handler list protected by mutex; register/deregister safe from any thread. |
| Logger | Sink and level accesses protected by mutex. |
StrandExecutor |
Per-strand mutex + atomic running flag; safe from any thread. |
Bridge::switchBackend |
Holds bridge mutex while staging + committing; re-registration and notification are atomic with respect to new execute calls. Exception-safe: a registration failure rolls back and leaves the old backend and all currentIds untouched (no-op). Outgoing-backend cancellation runs after the mutex is released. |
Model::execute(action) throws
└─ LocalBackend's strand task catches via try/catch
└─ CompletionState::setException(current_exception())
└─ .onError(fn) handler posted to GUI executor
└─ fn receives exception_ptr; caller rethrows to inspect
If the Completion is abandoned (no .onError attached, or no callback executor to deliver it on), the destructor logs the exception through the orphan logger. Non-std::exception types are logged as "unknown exception".
Task exceptions on the executors themselves are handled independently: ThreadPoolExecutor and StrandExecutor catch and log every task throw via morph::log, and MainThreadExecutor::runFor catches std::exception. A throwing task therefore never kills a worker or stalls a strand — see "Executors" above.
- Define the model struct with
executeoverloads:
struct MyAction { int x = 0; };
struct MyModel {
int execute(const MyAction& a) { return a.x * 2; }
};- Specialise traits and register (in a
.cppthat owns the model):
#include <morph/registry.hpp>
BRIDGE_REGISTER_MODEL (MyModel, "MyModel")
BRIDGE_REGISTER_ACTION(MyModel, MyAction, "MyAction")- Use from the GUI (same code for local and remote):
morph::bridge::Bridge bridge{std::make_unique<morph::backend::LocalBackend>(pool)};
morph::bridge::BridgeHandler<MyModel> handler{bridge, &guiExecutor};
handler.execute(MyAction{21})
.then([](int result) { /* runs on GUI thread */ })
.onError([](std::exception_ptr e) { /* runs on GUI thread */ });A BridgeHandler normally registers its own model instance. A model that
declares a primary key can instead have its instances shared: handlers that
name the same key reach one instance, through a directory the server owns, so
the sharing spans clients and not merely handlers.
The key is declared beside the registrations, never inside the model class:
BRIDGE_MODEL_KEY(AccountModel, LoadAccount, &LoadAccount::id); // key type deduced
BRIDGE_KEY_FROM(CloseAccount, &CloseAccount::id); // also carries itBRIDGE_MODEL_KEY appears once per model — it specialises
ModelKeyTraits<Model>, which cannot be repeated — and every other action
naming the same entity uses BRIDGE_KEY_FROM. Actions with neither declaration
are keyless, which is the common case: they run against whichever instance
their handler is already attached to.
| Aspect | Default |
|---|---|
| Opt-in | BridgeHandler<M, AllowShared>. Plain BridgeHandler<M> keeps a private instance and never enters the directory. |
| Attachment | Automatic: executing a keyed action attaches, or re-points, the handler to that key's instance, constructing one only if none is live. |
| Re-pointing | A keyed action naming a different key moves the handler. Instances never change identity, so a key always maps to one instance. |
| Lifetime | Refcounted across every attachment, including across connections. The instance dies when the last one goes. |
| Ownership | A shared instance is recorded with no owner principal — per-instance ownership and cross-client sharing are mutually exclusive. |
| Creating actions | BRIDGE_MODEL_KEY_FROM_RESULT takes the key from the reply and promotes the instance the action ran on, so nothing it built is stranded. |
Full design, including the wire additions (primary/shared fields and the
attach/assign/instances kinds) and the connection-scope refcount, is in
docs/spec/core/shared_instances.md.
The framework offers two complementary surfaces for talking to a model:
- One-shot:
handler.execute(action) -> Completion<R>. The action is built at the call site and dispatched in one call. - Observing:
handler.subscribe<R>(cb)fires whenever anRis produced on the instance this handler is attached to — by this handler, by another handler sharing that instance, or by another screen entirely. Suitable for a view that renders some model state and must stay current when anything changes it.
A subscription names the result/state type, not an action. The subscriber
describes what it renders rather than what somebody else must call to produce
it, so adding an action that also yields an R never breaks an existing
subscriber.
morph::bridge::BridgeHandler<AccountModel, morph::bridge::AllowShared> screen{bridge, guiExec};
morph::bridge::BridgeHandler<AccountModel, morph::bridge::AllowShared> sidebar{bridge, guiExec};
screen.attach(42);
sidebar.attach(42); // same instance
screen.subscribe<AccountInfo>([](AccountInfo info) { renderBalance(info); });
sidebar.execute(Deposit{.amountMinor = 5000});
// -> screen's callback runs: it never had to know Deposit exists| Aspect | Default |
|---|---|
| Keying | On the result type R. Any action producing an R notifies. |
| Scope | The instance the handler is currently attached to. Matched at publish time, so a subscription follows a re-pointed handler. |
| Subscriber cardinality | One callback per (handler, R). subscribe<R>(cb) replaces any prior callback. |
| Echo | The originating handler is notified too — no "was this mine" bookkeeping in subscribers. |
| Failures | A failed action notifies nobody. |
| Ordering | Per instance, guaranteed by that instance's strand. Nothing is guaranteed between instances. |
| Durability | None. Best-effort and unbuffered: no replay, no cursor, no coalescing. |
| Callback thread | Always the executor passed at handler construction. |
Three headers extend actions from "any aggregate" to self-describing
aggregates a client can build its GUI from a runtime: rational.hpp (exact
numbers), quantity.hpp (unit-tagged, optionally-empty values) and
forms.hpp (JSON Schema generation). Nothing else in the framework includes
them — they are an opt-in layer that composes with registration, validators,
and the wire. examples/forms demonstrates the whole loop with two
renderers: a self-contained HTML page and a Qt Quick client
(MORPH_BUILD_FORMS_QML=ON), both driven purely by the generated schemas.
A trivially-copyable numerator/denominator pair (int64_t) plus a
DecimalPlaces strong type. Arithmetic is exact and reduces to canonical
form; binary operations propagate the wider precision; comparison ignores
precision entirely. Fallible operations (operator/, fromFloat) return
std::expected<Rational, RationalError>, and mixed expressions containing an
expected or a floating-point operand evaluate to expected with
left-to-right error short-circuiting.
On the wire a Rational is {"num":617,"den":50,"dp":2}. The Glaze codec
routes every read through the canonicalising constructor: a non-canonical
payload (1234/100) lands reduced, a hostile one (den == 0, out-of-range
dp) is clamped rather than asserted — wire input is untrusted by design.
enum class Unit : std::uint16_t { scalar, kg, m3, kg_per_m3 };
template <> struct morph::units::UnitTraits<Unit> {
static constexpr morph::units::UnitMeta meta(Unit) noexcept { /* id, display, decimals */ }
};
consteval Unit operator/(Unit lhs, Unit rhs) { /* kg / m3 -> kg_per_m3, else throw */ }
using Mass = morph::units::Quantity<Unit::kg>;
using Volume = morph::units::Quantity<Unit::m3>;
// Mass{...} / Volume{...} deduces Quantity<Unit::kg_per_m3> at compile time.Design decisions, in order:
- One kind of empty. The blank state ("not entered", "not measured")
lives inside the quantity as
std::optional<Rational>; action structs never wrap aQuantityin anotherstd::optional. Whether a field may still be empty at submit time is field metadata (below), not a second wrapper type. Empty propagates through arithmetic (spreadsheet/SQL-NULL semantics); division by zero also yields empty. - Units are types, defined by the application. morph ships no unit enum.
The app supplies its own enum, a
UnitTraitsspecialisation (schema id, display text, default decimals) and aconstevalalgebra;Quantity'soperator*/operator/deduce result units from that algebra, and unsupported combinations fail to compile at the call site. Unit ids are protocol vocabulary: append enumerators, never renumber or rename. - Units never travel. The wire payload is just the nullable Rational
(
glz::metaunwraps the member), so a client cannot send a mismatched unit. Units appear in generated schemas (ExtUnits) and in C++ types only. - Declared precision lives in the type; actual precision is runtime
data.
Quantity<U, Decimals>'s second argument is the field's declared decimal count — defaulted fromUnitTraits, overridable per field (Quantity<Unit::m3, 4>) — and feedsx-decimalPlacesandfromDouble. The value's actual precision is the Rational's runtime tag: it max-propagates through arithmetic and is adjustable at run time (withDecimalPlaces/atDeclaredPrecision). Same-unit quantities convert freely across declared precisions; computed temporaries carry the unit default.
schemaJson<A>() wraps Glaze's write_json_schema (which contributes types,
$defs, and any per-field metadata declared via glz::json_schema<A>) and
closes the gaps a form renderer needs:
required— derived, not declared twice: a member is required unless it is astd::optional<...>or named in the action'sstatic constexpr std::array optionalFields{...}opt-out list.x-decimalPlaces— on everyQuantityproperty, fromUnitTraits, so the client knows the input step.x-order— the member's declaration index on every property, so field layout survives schema round-trips through order-losing DOMs.
allRequiredEngaged(action) is the matching readiness predicate ("every
required empty-capable field is engaged" — Quantity, Choice, Timestamp,
or any type with a hasValue()) intended as the body of the action's
validate() — which the existing ActionValidator resolution picks up
automatically. One declaration then drives the schema's required array, the
client-side submit gate, and the server-side readiness check.
Two further field types follow the same one-kind-of-empty pattern:
Timestamp(overmorph::time::DateTime, adapted from LASTRADAToolbox/Chrono.hpp): a UTC instant travelling as a strict ISO-8601 string ("2026-07-05T14:30:00.000Z"). The parser is hand-rolled (no locale, nostd::chrono::parse) so behaviour is identical across standard libraries, and — unlike the clampingRationalcodec — a malformed timestamp is a JSON read error: there is no meaningful clamp for a mistyped instant. Schemas carry the standard"format": "date-time"annotation.- Unit switching (
UnitTraits<E>::relations): a unit system may declare convertible entry units per canonical unit with exact rational ratios (grams -> kilograms as{g, 1, 1000}). They surface asx-unitAlternativesin the schema; renderers offer a unit selector and recalculate the entered value exactly on switch, and payloads always carry the canonical unit — the model never sees display units. The alternatives list is derived from the samerelationsthat driveconvert— there is no separatealternativesdeclaration to keep in sync. Choice<T, "ListSamples", "id", "name">: declares in the type that the field is not free input — its options are the rows returned by executing the named action (itself just a registered action, typicallyLoggable::No). The schema carriesx-optionsAction/x-optionValue/x-optionLabel; renderers execute the options action, build a combo box from the rows, and submit the selectedvalueFieldas the payload. On the wire aChoiceis its bare nullable value — options metadata never travels. The demo's HTML page resolves options at emit time (a static page cannot fetch); the QML client fetches them live over the same in-process wire it submits on.
The headers are grouped into per-sub-domain subdirectories that mirror the
namespaces. Design specs live under docs/spec/<sub-domain>/ with the same
folder names. One exception: include/morph/version.hpp sits directly under
include/morph/, with no subdirectory — it is cross-cutting library
metadata rather than part of any one sub-domain, so it is documented in the
top-level docs/spec/VERSIONING.md instead of a docs/spec/<sub-domain>/
folder.
| Header | Responsibility |
|---|---|
core/logger.hpp |
LogLevel, log configuration and level helpers; internals in morph::log::detail |
core/executor.hpp |
IExecutor, ThreadPoolExecutor, MainThreadExecutor (morph::exec::) |
core/strand.hpp |
ModelId, ModelIdHash, StrandExecutor — serialises tasks per model (morph::exec::detail::) |
core/completion.hpp |
CompletionState<T> (detail) + Completion<T> (public) — result handle |
core/model.hpp |
IModelHolder, ModelHolder<T>, ModelFactory, IBackendChangedSink, BackendChangedNotifiable — type-erased model storage; IModelHolder::attachActionLog/hasActionLog/recordIfAttached (morph::model::detail::) |
core/registry.hpp |
ModelTraits<>, ActionTraits<>, ActionValidator<>, ActionLogPolicy<>, Loggable (public) + ActionDispatcher (also tracking each action's coalesce policy), ModelRegistryFactory, defaultDispatcher(), defaultRegistry(), ParseError, registerModelOnce, registerActionOnce, actionLoggable<A>() (detail). Registration macros BRIDGE_REGISTER_MODEL, BRIDGE_REGISTER_ACTION (optional 4th Loggable argument), BRIDGE_REGISTER_VALIDATOR are defined here at file scope. |
core/backend.hpp |
LocalBackend (public) + ActionCall, IBackend (detail), including the non-breaking registerModelWithContext() default method |
core/remote.hpp |
RemoteServer (with setLogProvider()), SimulatedRemoteBackend (morph::backend::) |
core/bridge.hpp |
Bridge, BridgeHandler<M> (public) + HandlerBinding (carrying contextKey), MemberPointerTraits (detail) |
core/wire.hpp |
Envelope, encode/decode, kMaxEnvelopeBytes (morph::wire::) — the JSON wire envelope and length-bounded framing between any client and RemoteServer |
| Header | Responsibility |
|---|---|
journal/action_log.hpp |
LogEntry, IActionLog, InMemoryActionLog, toJson/fromJson, SerializationError, setActionLog, defaultActionLog, ScopedActionLog (morph::journal::) — the durable-sink interface and the process-wide default sink, with zero dependency on core/model.hpp/core/registry.hpp |
journal/journal.hpp |
SessionLog, replay() (morph::journal::) — full-fidelity session log with checkpoint() coalescing and undoLast(), built on action_log.hpp + the existing ActionDispatcher/ModelRegistryFactory |
journal/file_action_log.hpp |
FileActionLog (morph::journal::) — append-only NDJSON IActionLog, flush() fsyncs |
| Header | Responsibility |
|---|---|
offline/network_monitor.hpp |
NetworkMonitorConfig, NetworkMonitor — background probe thread, online/offline state machine |
offline/offline_queue.hpp |
IOfflineQueue, QueueItem, InMemoryOfflineQueue — durable write queue abstraction |
offline/sync_worker.hpp |
SyncWorker, SyncResult — drains offline queue on reconnect via caller-supplied replay |
offline/reconnect_coordinator.hpp |
ReconnectCoordinator, ReconnectOutcome, ReconnectCoordinatorConfig — sequences reconnect → activate → bind → replay on connectivity return; all side effects injected via Deps |
| Header | Responsibility |
|---|---|
session/session.hpp |
Context, IAuthorizer, AllowAllAuthorizer, allowAllAuthorizer, current (morph::session::) — per-call session bag carried through the bridge to the model, plus the server-side authorization seam; internals in morph::session::detail (ScopedContext, thread-local current context) |
session/session_auth.hpp |
SessionToken, TokenIssuer, TokenVerifier, SigningAuthorizer, MacFunction, hmacSha256, AuthError (morph::session::) — opt-in signed bearer tokens and a verifying IAuthorizer; self-contained reference HMAC-SHA256 in morph::session::detail. See docs/spec/security.md. |
| Header | Responsibility |
|---|---|
forms/forms.hpp |
schemaJson<A>(), allRequiredEngaged(), EmptyCapableField (morph::forms::) — JSON Schema per action with derived required, x-decimalPlaces, x-order, x-options* |
forms/choice.hpp |
Choice<T, "ListX", "id", "name">, FixedString, isChoice (morph::forms::) — a field whose options are the result of executing the named action; surfaces as x-optionsAction/x-optionValue/x-optionLabel in schemas, renders as a combo box |
| Header | Responsibility |
|---|---|
util/rational.hpp |
Rational, DecimalPlaces, RationalError (morph::math::) — exact int64 rational arithmetic with a decimal-precision tag; Glaze wire codec ({"num","den","dp"}, canonicalised on read) and std::formatter |
util/quantity.hpp |
Quantity<U>, UnitMeta, UnitTraits (morph::units::) — unit-tagged optional value over Rational; units are application enum NTTPs, schemas get ExtUnits automatically. See docs/spec/util/quantity_type.md for the full design. |
util/datetime.hpp |
DateTime, Timestamp (morph::time::) — UTC instant (ms precision) with a strict ISO-8601 wire codec (malformed input is a read error) and the optionally-empty field wrapper; schemas carry "format": "date-time" |
| Header | Responsibility |
|---|---|
qt_executor.hpp |
QtExecutor — posts callables via QMetaObject::invokeMethod |
qt_websocket_backend.hpp |
QtWebSocketBackend — WebSocket client; implements IBackend |
qt_websocket_server.hpp |
QtWebSocketServer — QObject server; forwards messages to RemoteServer |
RemoteServer::handle() captures shared_from_this() to prevent a use-after-free if the worker pool outlives the server. This means RemoteServer must be created via std::make_shared<morph::backend::RemoteServer>(...). Constructing it on the stack and calling handle() will throw std::bad_weak_ptr at runtime.
Callbacks (onOffline, onOnline) run directly on the probe thread. A blocking call inside a callback will delay or prevent subsequent probes, and a blocking stop() call from within a callback will self-deadlock on the thread join. stop() detects this case and detaches instead of joining; the monitor thread completes its current iteration and exits, and the destructor spin-waits until it does. The intent is that callbacks should be short — typically just setting an atomic flag or posting to an executor.
switchBackend holds Bridge::_mtx for its entire duration and calls notifyBackendChanged() while still holding it. onBackendChanged() is invoked from inside that call. If an onBackendChanged() implementation calls switchBackend or registerHandler / deregisterHandler (which also acquire _mtx), the thread will self-deadlock. executeVia is safe to call from onBackendChanged() because it uses a lock-free snapshot of the backend.
The cbExec pointer on CompletionState should be set before setValue / setException is called (or before attachThen / attachOnError if the state is already ready). If cbExec is null when a callback would fire, that callback is silently dropped — no error is raised. This is enforced by the Completion<T> constructor, which accepts an IExecutor*; the hazard only arises when using CompletionState directly (an internal type).
An abandoned error, however, is not silenced by a null executor: onErrAttached (the flag that suppresses the destructor's orphan log) is only set when an executor actually exists to deliver the handler. With a null executor the error handler is never posted, so onErrAttached stays false and ~CompletionState still logs the exception through the orphan logger rather than losing it.
If runFor(timeout) returns because the timeout expired rather than because the queue emptied, any remaining tasks stay enqueued. A subsequent runFor call will process them. This is intentional — runFor is a pump, not a flush.
morph is 0.1.0 and pre-1.0: per Semantic Versioning's
own rule for major version 0, any release may still change anything without
a major bump. The semantic-versioning, stable-surface, and deprecation-window
commitment morph makes starting at 1.0 is fully specified in
docs/spec/VERSIONING.md — this section is only a
pointer, not a substitute. In short: the per-topic public namespaces above
(everything outside a detail namespace) are the stable surface; because
morph is header-only there is no ABI to preserve, so the promise is source
compatibility, checked against both a symbol's signature and its docs/spec/
documented behavior.
| Decision | Rationale |
|---|---|
| Header-only library | Zero build-system friction; include and use. |
Per-topic public namespaces with per-topic detail:: |
Minimal public surface — callers see only what they need; internals are clearly walled off. |
StrandExecutor per ModelId |
Parallelism across models; serial within one model — model authors write single-threaded code. |
Completion<T> not std::future<T> |
Callbacks marshal to a specific executor; futures do not. |
IBackend in detail:: |
Users never type the interface — they construct concrete backends and let conversion happen implicitly. |
HandlerBinding with atomic currentId |
Handlers survive backend replacement without re-registering from application code. |
LogLevel : uint8_t |
Minimises storage; 5 levels fit in one byte. |
| Glaze for JSON | Reflects aggregate types automatically; no hand-written serialisation per action. |
CompletionState<T> internal only |
Keeps the public API free of state-handling machinery; implementation can change without breaking callers. |
JSON Envelope wire protocol |
Self-describing and forward-compatible (unknown keys ignored); carries a callId so async WebSocket replies can be correlated back to pending Completion objects. |
| Subscriptions keyed on the result type, scoped to the instance | A subscriber is a renderer: it names the state it draws, not the actions that produce it, so a new action never breaks it. Scoping to the instance is what lets two screens on one shared model see each other's work without a query-invalidation vocabulary. |
ActionValidator<A> is action-typed, not model-typed |
Different actions on the same model have different readiness requirements; pinning the predicate to the action keeps GUI code oblivious to model internals. |
set<auto FieldPtr>(value) over set<Action>(&Action::f, value) |
Member-pointer NTTP encodes both the action type and the field type; the call site stays terse without losing type safety. |
Rational wire codec canonicalises on read |
Wire input is untrusted; every deserialised value passes the reducing constructor, so invariants hold no matter what a client sends. |
One optionality: empty state inside Quantity |
Drafts and lab data genuinely have "no value yet" with the unit still known; a second std::optional wrapper would split one concept across two types. |
| Units are enum NTTPs with app-defined algebra | Mixing units is a compile error and result units are deduced, while morph stays domain-agnostic — the application owns the enum, metadata, and algebra. |
required derived from types + one opt-out list |
The same declaration drives the schema, the client submit gate, and validate() — required-ness cannot drift between server and GUI. |
Combo-box options declared as an action reference (Choice<T, "ListX">) |
Option lists are living data, so the single source is the action that serves them; the schema only carries the reference, and every renderer resolves it through the same dispatch seam as submits. |
| Strict (non-clamping) datetime codec | Rational clamps hostile input because any int pair still denotes a value; a malformed timestamp denotes nothing — rejecting the read beats fabricating an epoch. |