|
morph 0.1.0
Asynchronous bridge between GUI thread and business models
|
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 on core-cpp for its event-loop timers, base64 and wakeup primitive, and optionally integrates with Qt 6 via a separate target. core-cpp's core::base, core::net and core::platform are static libraries, so while morph's own surface is headers only, a project that links morph::morph also builds those.
New to morph? docs/GETTING-STARTED.md is the step-by-step tutorial that comes before this document: it builds one small app end to end (examples/bookmarks) and explains why each piece exists. This page assumes you are already oriented. (Named, not linked, for the Doxygen reason below.)
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. docs/spec/README.md indexes all of them, and is the quickest way to find the one you need: it groups every spec by the question it answers and traces one action end to end. (Named, not linked: Doxygen reads this file but not docs/spec/, and WARN_AS_ERROR turns a link it cannot resolve into a failed docs build — which is why every spec reference below is backticked too.) 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, EffectsRegistrationError |
| morph::record | Result types that are shared data (see docs/KEEPING-SCREENS-CURRENT.md) | BRIDGE_REGISTER_RECORD, key<&T::m>, keyless, derivedFrom<S...>, privateKeys, RecordInfo |
| morph::effects | What an action changes, and the changes an execute made | BRIDGE_REGISTER_EFFECTS, updates<R>, changes<R>, changesInstance, none, Effect, ActionRecord, effectsOf, recordsOf, recordNamed, actionsHolding, deriveChanges, Changes, kMaxChangeKeys |
| 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, OfflineQueueFullError, InMemoryOfflineQueue, FileOfflineQueue, FileOfflineQueueError, SqliteOfflineQueue, SqliteOfflineQueueError, SyncWorker (incl. DeadLetterSink), 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, OutboxRelay, OutboxRelayResult, PayloadMigrationRegistry, SchemaMismatchError, SerializationError, NullSinkError, 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, and the forms engine that reads it back and renders it through the view tree (see docs/spec/forms/engine.md) | schemaJson<A>(), allRequiredEngaged(), Choice<T, ...>, FixedString, isChoice, EmptyCapableField, BlankAs; FormModel, FieldSpec, RuleExpr, FormSession, FieldState, Submitter, ChoiceFetcher, handlerSubmitter, Form<A, M, S>, CollectionSession, WizardSession, formView, collectionView, wizardView |
| morph::reactive | Signal graph, view state and declarative control (see docs/spec/reactive/) | Runtime, RuntimeOptions, Signal<T>, Computed<T>, Effect, Scope, EqualityPolicy, kEqualityUsable, Store<ViewState, Msg>, ExhaustiveUpdate, request(), Query<A, R>, QueryOptions, InvalidationLink, Mutation<A, R>, MutationOptions<A>, Concurrency, Subscription<R>, Refetchable, errorMessage, Scheduler, TimerHandle; testing::ManualScheduler |
| morph::ui | The UI document and expr/1, its interpreter, the view tree, mount and the frontend seam (see docs/spec/ui/) | Value, Type, Document, DocNode, Command, Catalog, loadDocument, Interpreter, Screen, Session, Dispatcher, BridgeDispatcher, ModelChannel, InterpreterHost, Expr, parseExpression, checkExpression, evaluate, Environment, StaticEnvironment, parseMessage, formatMessage, Node, Prop<T>, SlotId, slot(), Key, Common, Accessibility, KeyBinding, FieldState, MenuItem, ActionButton, Step, KeyValueItem, the node kinds and their builders, forEach, table, switchOn; Widget, ContainerWidget, FieldWidget, the widget interfaces, MenuEntry, IViewBackend; Mounted; AppContext, AppSource, Bundle, ConnectError, Backend, Frontend, FrontendOption, FrontendError, EnvironmentReader, processEnvironment, selectFrontend, MountedShell, ShellFactory, runApp; testing::RecordingBackend, testing::ConformanceProbe, testing::conformanceCases; the UI service's UiRegistry, ServedBundle, UiService, UiClient, VerifiedCache, IBundleStore, MemoryBundleStore, Manifest, CatalogReply, contentHash; author:: — Handle, Scope, DeclScope, Screen, ScreenRegistry, checkAccessibility, MORPH_REGISTER_SCREEN |
| morph::testing | The installed owner test kit | StepExecutor, OwnerProbeRecorder |
| morph::tui | The terminal renderer and frontend (built only when MORPH_BUILD_TUI=ON; see docs/spec/tui/) | Backend, Frontend, FrontendConfig, ShellFactory, frontendOption, kInterruptExitCode, LoopExecutor |
| morph::qt | Qt integration (built only when MORPH_BUILD_QT=ON) | QtExecutor, QtWebSocketBackend, QtWebSocketServer |
| morph::qt_quick | The Qt Quick renderer and frontend (built only when MORPH_BUILD_QT_QUICK=ON; see docs/spec/qt_quick/) | Backend, BackendOptions, CustomWidget, CustomRequest, widthClassOf, Components, ComponentFailure, Frontend, FrontendConfig, ShellFactory, frontendOption, usableHere, QtScheduler |
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.
Local mode — model lives in the same process:
Simulated-remote mode — model lives behind an in-process RemoteServer (used in tests):
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.
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):
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). A kind the server does not answer itself goes to the IEnvelopeServices in ServerConfig::services; the UI service's ui-hello, ui-catalog and ui-fetch arrive that way (docs/spec/ui/service.md), and a server without it answers them unknown envelope kind.
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):
A model instance's strand (morph::exec::detail::ModelStrands, 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::ModelStrands guarantees that all tasks for the same ModelId are serialised while tasks for different models are parallelised. It is core-cpp's core::async::KeyedStrands<ModelId> over the backend's IExecutor: a strand per model instance with work, made when it gets work and retired when it runs out, which queues itself on the executor once per turn and runs a batch of tasks there. morph adds the adapter to its IExecutor, the catch-and-log above, and the Task handler's session around each of the handler's resumptions (see spec/core/coroutines.md).
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:
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 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:
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::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 that belongs to the executor the queue is given at construction: its verbs run there, and a caller elsewhere posts its writes and asks its reads through completion overloads (see docs/spec/offline/offline.md, "One owner"). 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::ServerConfig::logProvider (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 (ServerConfig::logProvider) 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.).
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: it owns a strand over the executor it is given, and onOnline() / onOffline() post their bodies there, so they run one at a time in call order. onOnline(replyExec) returns a Completion<ReconnectOutcome> delivered on the caller's replyExec (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:
| 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. |
| ModelStrands | core-cpp's KeyedStrands: a registry lock taken before each strand's own; 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. |
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 ModelStrands 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.
An action that changes data also says what it changes, so every screen showing it refreshes (docs/KEEPING-SCREENS-CURRENT.md):
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 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:
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.
| 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. |
subscribe<R> reaches handlers on one Bridge. What keeps every client's screens current, and an agent's changes visible to them, is declared effects:
The user guide is docs/KEEPING-SCREENS-CURRENT.md; the rules are in docs/spec/core/registry.md ("Effects"), docs/spec/core/backend.md ("Fan-out"), docs/spec/ui/interpreter.md and docs/spec/ui/document.md.
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 the forms engine's screens in the generic client, 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.
Design decisions, in order:
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:
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:
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, ModelStrands, TaskResumer — serialises tasks per model over core-cpp's KeyedStrands (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/records.hpp | BRIDGE_REGISTER_RECORD and its options key<&T::m>, keyless, derivedFrom<S...>, privateKeys; RecordInfo (morph::record::) — result types that are shared data. See docs/spec/core/registry.md, "Record registry". |
| core/effects.hpp | BRIDGE_REGISTER_EFFECTS and updates<R>, changes<R>, changesInstance, none; Effect, ActionRecord, effectsOf, recordsOf, recordNamed, actionsHolding (morph::effects::); EffectsRegistrationError (morph::model::) — what each action changes, matched with the records its result holds and validated on the first read. See docs/spec/core/registry.md, "Effects". |
| core/changes.hpp, core/change_types.hpp | deriveChanges, Changes, RecordChange, InstanceChange, kMaxChangeKeys (morph::effects::) — the changes a successful execute made. |
| core/connection_writer.hpp | ConnectionWriter, kEventBacklogHigh, kEventBacklogLow (morph::backend::) — how RemoteServer writes events to a connection, and the backlog above which it holds them back. |
| core/backend.hpp | LocalBackend (public) + ActionCall, IBackend (detail), including the non-breaking registerModelWithContext() default method |
| core/remote.hpp | RemoteServer (configured by ServerConfig, incl. logProvider), 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 |
| forms/blank_as.hpp | BlankAs (morph::forms::) — what a blank string field submits, named by the emitters and the engine |
| forms/engine/field_model.hpp | FieldKind, SubmitMode, FieldSpec, FieldGroupSpec, FormModel (morph::forms::) — a form's immutable model, read from the emitted schema JSON. See docs/spec/forms/engine.md. |
| forms/engine/rules.hpp | RuleExpr, Tri, RuleOperand, gatingVerdict, visibleVerdict, readonlyVerdict, requiredVerdict (morph::forms::) — x-rules translated into expr/1 and evaluated by the UI document's evaluator |
| forms/engine/values.hpp | encodeScalar, decodeScalar, convertDraft, parseOptions, FieldError, WireValue (morph::forms::) — exact encoders and decoders between draft text and the wire |
| forms/engine/form_session.hpp | FormSession, FieldState, RowState, Submitter, ChoiceFetcher, FormSessionOptions (morph::forms::) — the reactive, headless state of one form |
| forms/engine/handler_submitter.hpp | handlerSubmitter, handlerChoiceFetcher (morph::forms::) — a form submitting through the application's own BridgeHandlers |
| forms/engine/typed_form.hpp | Form<A, M, S>, FormModel::forAction<A>() (morph::forms::) — a form over a registered action type, with typed access |
| forms/engine/overrides.hpp | Overrides, FieldView (morph::forms::) — replacement controls by field path, widget, unit or kind |
| forms/engine/form_view.hpp | formView, FormViewOptions (morph::forms::) — a form session rendered as a view tree |
| forms/engine/collection.hpp | CollectionModel, CollectionSession, CollectionRow, SchemaLookup (morph::forms::) — a v-* view as a reactive list, its table::VectorSource, row and collection actions, the confirm step and the row editor |
| forms/engine/wizard.hpp | WizardModel, WizardSession (morph::forms::) — a w-* wizard as one form per step, sequenced by successful submissions, with prefill from earlier steps |
| forms/engine/collection_view.hpp | collectionView, CollectionViewOptions, CollectionChrome (morph::forms::) — a collection session rendered as a view tree: the table, its actions, the confirm and editor dialogs or master-detail |
| forms/engine/wizard_view.hpp | wizardView, WizardViewOptions, WizardChrome (morph::forms::) — a wizard session rendered as a view tree: the header, the current step and Back/Next |
| 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 |
|---|---|
| reactive/runtime.hpp | Runtime, RuntimeOptions (morph::reactive::) — the owner, batching, one posted flush per change, widget-event deferral. See docs/spec/reactive/signals.md. |
| reactive/signal.hpp | Signal<T>, Computed<T>, Effect, EqualityPolicy, kEqualityUsable — the graph's nodes |
| reactive/scope.hpp | Scope — owns nodes and objects, destroys them newest first, nests (child(), depth()) |
| reactive/store.hpp | Store<ViewState, Msg>, ExhaustiveUpdate, request() — user-intent view state changed only by messages. See docs/spec/reactive/store.md. |
| reactive/control.hpp | Query, QueryOptions, InvalidationLink, Mutation, MutationOptions, Concurrency, Subscription, Refetchable, errorMessage — server interaction as reactive state. See docs/spec/reactive/control.md. |
| reactive/scheduler.hpp | Scheduler, TimerHandle — timers a frontend runs on the owner, for a query's timed refresh and debounce |
| reactive/testing/manual_scheduler.hpp | testing::ManualScheduler — a Scheduler whose clock moves only when a test advances it |
| reactive/detail/graph.hpp | RuntimeCore, Node, TrackingFrame, Colour, FlushBounds, the misuse site names, kDefaultMaxEffectRunsPerFlush, kDefaultMaxThrowReposts (morph::reactive::detail::) |
| Header | Responsibility |
|---|---|
| ui/value.hpp | Value, ValueKind, ValueList, ValueRecord, Decimal, Quantity, Type, TypeKind, parseType, valueFromJson, valueToJson, parseJson, Json, Refusal, pointerAppend (morph::ui::) — the document's typed values and types and their JSON form. See docs/spec/ui/document.md. |
| ui/message.hpp | MessagePattern, parseMessage, formatMessage, MessageLocale, FormattedMessage, PluralCategory, pluralCategory, formatNumber — the MessageFormat 2 subset of t and its CLDR plural rules. See docs/spec/ui/expressions.md. |
| ui/expression.hpp | Expr, ExprKind, FunctionId, parseExpression, NameResolver, PermissiveResolver, checkExpression, Environment, StaticEnvironment, FormatContext, UnitTable, UnitInfo, evaluate, walk, isName, functionNames — the expr/1 language. See docs/spec/ui/expressions.md. |
| ui/document.hpp | Document, ScopeDecl and the declaration structs, Command, CommandKind, DocNode, Component, CustomizePoint, Catalog, ModelInfo, ActionInfo, LoadOptions, loadDocument, loadDocumentText, nodeKinds — the document model and its load-time validation. See docs/spec/ui/document.md. |
| ui/interpreter.hpp | Interpreter, Screen, Session, SessionOptions, Dispatcher, ModelChannel, CallOptions, BridgeDispatcher, InterpreterHost, NavigateRequest, InterpreterOptions, MountOptions, BorrowedModel (morph::ui::) — mounts a loaded document: scopes, the session's query cache, mutations, relations and commands, lowered to the view tree. See docs/spec/ui/interpreter.md. |
| ui/agent.hpp | McpAdapter, McpOptions, serveStdio and the JSON-RPC error codes (morph::ui::) — an AI agent over MCP, as one more connection to the server. See docs/spec/ui/agent.md. |
| ui/author.hpp | Handle, AnyHandle, Body, ModelHandle, QueryHandle, MutationHandle, DeclScope, Scope, Command, Commands, Node, Nodes, a builder per node kind and command, Screen, screen, ScreenRegistry, screenRegistry, CatalogFacts, ModelFacts, ActionFacts, checkAccessibility, MORPH_REGISTER_SCREEN (morph::ui::author::) — server-side authoring: typed builders that write a screen's document, and its registration with the load-time and accessibility rules. See docs/spec/ui/author.md. |
| ui/view.hpp | Node, NodeData, Prop<T>, SlotId, SlotBinding, slot(), Key, Common, Accessibility, KeyBinding, FieldState, MenuItem, Sizing, LayoutHints, every node kind and its builder, switchOn, forEach, table (morph::ui::) — the immutable view tree whose properties are constants or slots. See docs/spec/ui/view_tree.md. |
| ui/backend.hpp | Widget, ContainerWidget, FieldWidget, one widget interface per kind, MenuEntry, IViewBackend — the retained widgets a renderer implements. See docs/spec/ui/backend_contract.md. |
| ui/mount.hpp | Mounted — builds a tree's widgets once, keeps them current through bindings, mounts content in child scopes, keeps keyed rows, controls inputs by their slots |
| ui/frontend.hpp | AppContext, AppSource, Bundle, ConnectError, Backend, Frontend, FrontendOption, FrontendError, selectFrontend, MountedShell, ShellFactory, runApp — the seam main picks a frontend through. See docs/spec/ui/frontend.md. |
| ui/testing/recording_backend.hpp | testing::RecordingBackend — the headless reference backend: operation log, golden dump, interaction helpers |
| ui/testing/backend_conformance.hpp | testing::ConformanceProbe, testing::ConformanceCase, testing::conformanceCases() — the scripted cases every backend passes |
| Header | Responsibility |
|---|---|
| testing/step_executor.hpp | StepExecutor (morph::testing::) — an executor whose posted tasks run only when a test steps it |
| testing/owner_probe_recorder.hpp | OwnerProbeRecorder (morph::testing::) — records every owner check while it is alive, instead of a debug build asserting |
Built only when MORPH_BUILD_TUI=ON, as the compiled STATIC library morph::tui over core-cpp's core::tui.
| Header | Responsibility |
|---|---|
| tui/backend.hpp | Backend (morph::tui::) — ui::IViewBackend over a core::tui::Screen: every widget kind as a core::tui component, laid out in cells. See docs/spec/tui/frontend.md. |
| tui/frontend.hpp | Frontend, FrontendConfig, ShellFactory, frontendOption, kInterruptExitCode — runs an AppSource in a terminal on one caller-driven IoLoop. |
| tui/loop_executor.hpp | LoopExecutor — the IExecutor that runs tasks in later turns of a core::net::EventLoop; the runtime's owner and every handler's callback executor. |
| 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 |
Built only when MORPH_BUILD_QT_QUICK=ON, as the compiled STATIC library morph::qt_quick with the private QML module MorphUi. The headers hold declarations only.
| Header | Responsibility |
|---|---|
| qt_quick/backend.hpp | Backend, BackendOptions, CustomWidget, CustomRequest, widthClassOf (morph::qt_quick::) — ui::IViewBackend over Qt Quick: every widget kind as an item made from its MorphUi component. See docs/spec/qt_quick/backend.md. |
| qt_quick/components.hpp | Components, ComponentFailure — bundle components verified by digest and compiled in a contained engine, host components by version, and the restyle map |
| qt_quick/frontend.hpp | Frontend, FrontendConfig, ShellFactory, frontendOption, usableHere — runs an AppSource in a Qt Quick window on Qt's event loop. See docs/spec/qt_quick/frontend.md. |
| qt_quick/scheduler.hpp | QtScheduler — reactive::Scheduler on QTimers, with the owner current in every callback |
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. morph's own surface stays headers only; core-cpp's static modules are built with it. |
| core-cpp for timers, base64 and wakeup | One implementation shared with the other Contour Terminal projects, including a WebAssembly subset whose host-driven loop runs TimeoutScheduler on the browser's main thread. |
| Per-topic public namespaces with per-topic detail:: | Minimal public surface — callers see only what they need; internals are clearly walled off. |
| A strand 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. |
What morph deliberately does not build, and why. morph is a UI bridge with one server that owns neither the store nor the tooling; each row below follows from that.
| Not building | Why |
|---|---|
| Entity metamodel / naked objects (Causeway) | morph's unit is the action, deliberately. Rows plus row-bound actions already are an object UI; what is missing is metadata on the row, not a second parallel metamodel. |
| Server-authoritative per-instance action availability ("see it, use it, do it") | A real gap — a client cannot ask "may I?" before invoking an action — but not worth the surface area it would add. |
| Query invalidation / live lists | Instance subscriptions already give shared instances a change channel, without a query-invalidation vocabulary. |
| Paging / sorting contract on view schemas | views.md already records "no server-side query language" as a non-goal; paging stays a field on the query action. |
| Result-type presentation metadata (money, enum labels, badge severity) | Every GUI controller hand-writes a toMap() projection. Genuine duplication, but it is a forms-layer concern, not a model-layer one. |
| Field- and row-level permissions (Jmix, Causeway SecMan) | They own the ORM and the whole app; morph owns a seam. Per-principal schema redaction would break the one-cached-schema-per-type design. The model is the right place, and bank's loadOwned guard shows it works. |
| BPM / BPMN engine (Axelor, Jmix) | A process engine without a store is meaningless, and morph does not own the store. |
| Grain call filters / interceptor pipeline (Orleans) | morph already has validator + authorizer + observe + journal on every dispatch path. No observed friction. |
| Clustering, silos, placement, grain versioning, stateless workers, distributed ACID transactions (Orleans) | morph is a UI bridge with one server, not a distributed runtime. |
| Managed streams with cursors and checkpoints (Orleans) | Instance subscriptions are best-effort and unbuffered by design; durability here is a distributed-runtime concern. |
| Reporting engine (BIRT, JasperReports), ORM + schema migration, IDE Studio | Out of identity. morph owns neither the store nor the tooling. |
| Runtime custom fields / dynamic attributes (Axelor, Jmix) | Hard against compile-time reflection, and no observed need. |
| REST / GraphQL facade, blob transfer, tabular export, multi-tenancy discriminator | Plausible, no observed need. Revisit when one exists. |