morph::model uses a trait-plus-singleton-registry pattern to map C++ model
and action types to string identifiers at static-init time, so that remote
frontends and schema-driven GUIs can discover, instantiate, and execute models
without knowing their concrete types.
- Overview
- Registration rules and invariants
- Customisation traits
- Validation and logging policy
- Type-erased holders and factory
- Singleton registries
- Registration macros
MORPH_CLIENT_ONLY— suppressing model-owning registrars- API reference
- Design decisions
- Thread safety
- Failure modes
- Limitations
- Cross-references
Every model and action type that participates in morph's remote or schema-driven infrastructure must be registered with a string id. The registration system provides:
- Traits —
ModelTraits<M>andActionTraits<A>that map types to string ids and JSON codecs. Users specialise them directly or use macros. - Validators —
ActionValidator<A>that decides whether a partially-built action draft is ready to execute, enforced on every dispatch path: themorph::flows::FlowSession::set<>gate, the type-erasedexecuteJsonpath, the server dispatch runner (ActionDispatcher::registerAction), and the localBridge::executeViapath — the last two throwValidationErroron afalseresult instead of runningModel::execute. ActionRecordingError— thrown when an action executed and its mutation committed, but serialising the result or appending the journal entry failed. Carriescause()(the underlying message) andresult()(the committed action's result JSON, or""when serialising it is what failed). Derives fromstd::runtime_error, so existingcatch (const std::exception&)paths are unaffected.- Logging policy —
ActionLogPolicy<A>andLoggablethat control whether an action's executions are recorded and how duplicates are coalesced. - Type-erased holders —
IModelHolder/ModelHolder<M>that own a model instance and carry an optional action log attachment. - Singleton registries —
ActionDispatcher(server-side dispatch),ModelRegistryFactory(model instantiation by string id), andActionExecuteRegistry(client/schema-driven generic execute). - Macros —
BRIDGE_REGISTER_MODEL,BRIDGE_REGISTER_ACTION,BRIDGE_REGISTER_VALIDATORthat specialise traits and register into singletons at static-init time.
Registration is not a runtime call the application makes; it is a side effect of static initialisation of file-scope objects the macros emit. That machinery only works if a handful of invariants hold. Read this section before adding a model or action to any target other than a single executable — the most common failure (a model that silently never registers) is a linking problem, not a code problem, and produces no diagnostic.
BRIDGE_REGISTER_MODEL and BRIDGE_REGISTER_ACTION each emit two things at
namespace scope:
- an explicit template specialisation —
ModelTraits<M>orActionTraits<A>— which has external visibility to the type system; and - one or more file-scope initialiser objects (
[[maybe_unused]] const boolin an anonymous namespace) whose initialisation runsregisterModelOnce/registerActionOnce/registerActionExecutorOnce.
BRIDGE_REGISTER_VALIDATOR emits only item 1 — an ActionValidator<A>
specialisation. It performs no static-init registration (there is no singleton
of validators; ActionValidator is consulted purely by template lookup).
Placing a BRIDGE_REGISTER_* invocation in a header included by many .cpp
files is well-formed, and is the ladder's standard practice — 40 headers under
examples/ invoke a BRIDGE_REGISTER_* macro.
This document previously called it an ODR violation. That legal claim was
false. Item (1) is an explicit specialisation — a class definition — and
[basic.def.odr] expressly permits a class to be defined in more than one
translation unit when the definitions are token-identical, which a shared
header #included unchanged guarantees. It is the same rule that makes any
ordinary header-defined class legal; no special exception is being threaded.
The claim was checked empirically as well as read from the standard: a two-TU
reproduction (a header invoking BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION,
included by tu_a.cpp and tu_b.cpp, both resolving the model through
ModelRegistryFactory) compiles, links and runs clean with zero
diagnostics on Clang 22.1.8 and GCC 15.3.0, and on Clang 22.1.3 and
MSVC 19.51.
Item (2) is what actually differs, and the cost is small. The anonymous
namespace makes each initialiser object internal to its TU, so the model is
registered once per including TU rather than once per program. That is
redundant work, not a defect: registerModelOnce forwards to
ModelRegistryFactory::registerModel, which does insert_or_assign, so the
repeat calls reassign an equivalent factory closure over the same key. The
observable end state is identical.
So choose placement on ordinary grounds — a header when several TUs need the
registration (see
BRIDGE_REGISTER_ACTION_FOR_CLIENT,
which prescribes exactly that), a .cpp when only one does and you would
rather not pay N static initialisers. What is not optional is that the
registration reaches the link: see the next section.
Static-init "guarantees registrations are live before main()" (see
Design decisions) only for translation units the linker
actually keeps. The initialiser object is never referenced by name from
application code — nothing has a symbolic dependency on it. Consequences:
- Object files / whole executables: an object file linked directly into an executable contributes its static initialisers, so registration works with no extra ceremony. This is the common case and the one the design optimises for.
- Static libraries (
.a/.lib): a linker pulls in an archive member only if some symbol in it is already referenced. A registration-only TU exposes no referenced symbol, so the linker drops the member and the initialiser never runs — the model or action silently never registers, and the first symptom is a runtimestd::runtime_error("unknown model type" / "unknown action") far from the cause. Force the member to be retained:--whole-archive/-Wl,--whole-archive(GNU/LLD),-force_load(Apple ld),/WHOLEARCHIVE(MSVC), or CMake's$<LINK_LIBRARY:WHOLE_ARCHIVE,...>. Prefer linking registration TUs into the executable's own object set when practical. - Dynamically loaded modules (
dlopen/LoadLibrary): registrations run when the module is loaded, i.e. aftermainhas started, not before. Any code path that could dispatch/create a plugin's model must run after the module is loaded; there is no ordering guarantee relative to other TUs' static init.
Remotely instantiated models must be default-constructible — unless registered with a custom factory
ModelRegistryFactory::create reaches models registered via
BRIDGE_REGISTER_MODEL (i.e. via the single-argument
registerModel<Model>(modelId)) through ModelFactory::create<Model>(),
which does std::make_unique<ModelHolder<Model>>() with no constructor
arguments (see model.hpp). Any model registered that way — the ordinary,
common case — must therefore be default-constructible. A model with no
accessible default constructor still compiles the macro (which only needs
ModelTraits) but fails to compile the factory instantiation.
This is no longer the only registry-constructed path, though: the
two-argument registerModel<Model>(modelId, factory) overload (see
ModelRegistryFactory below) lets factory build
the holder however it likes — including calling a non-default constructor —
so a model that needs injected dependencies (previously reachable only
through a custom Bridge::HandlerBinding::modelFactory closure, and therefore
only via Local-mode/in-process registration) can now be registered for
Socket-mode/remote instantiation too, by calling
ModelRegistryFactory::instance().registerModel<Model>(modelId, factory)
directly instead of (or in addition to, last-write-wins) relying on
BRIDGE_REGISTER_MODEL's default-construction registrar.
Maps a concrete model type to its string type-id. Must be specialised (or
BRIDGE_REGISTER_MODEL used) before the model can be registered. The
default is a forward declaration — using it without a specialisation
is an incomplete-type error.
template <typename Model>
struct ModelTraits; // forward — specialize or use BRIDGE_REGISTER_MODELMaps a concrete action type to its string id, JSON codec, result type, and
optional logging flag. The BRIDGE_REGISTER_ACTION macro generates a full
specialisation. Hand-written specialisations (used in tests) predate the
loggable member; the framework defaults to Loggable::Yes when it is absent
(see detail::actionLoggable()). The default is a forward declaration.
template <typename Action>
struct ActionTraits; // forward — specialize or use BRIDGE_REGISTER_ACTIONAll four JSON functions throw detail::ParseError (a std::runtime_error
subclass) on glaze encode/decode failure.
Both macros also generate a fifth member, static const std::string& payloadSchema(), returning morph::model::payloadFingerprint<A>() — a
structural fingerprint of the JSON shape the four codec functions read and
write. It is stamped on every journal entry recorded for the action and
compared on replay, which is what lets journal::replay() notice that the
shape which wrote an entry is not the shape it is about to decode it with. See
payload_schema.hpp below and
journal.md, "Payload schema fingerprint".
A hand-written ActionTraits need not provide it. Such a specialisation
may map its struct to entirely different JSON than reflection would — or may
name a type Glaze cannot reflect at all (tests/test_client_execute_deadline.cpp
registers an anonymous-namespace struct, which has no linkage and so cannot be
reflected traditionally) — so deriving a shape from the struct would describe
something that never reaches the journal. detail::actionPayloadSchema<A>()
returns the empty string for it, entries are recorded unstamped, and replay
treats them exactly as every build before the fingerprint existed did. Opting
in is a matter of defining the member.
namespace morph::model {
inline constexpr std::uint32_t kPayloadFingerprintScheme = 2;
template <typename A> const std::string& payloadShapeString(); // "(count:i4,state:s)"
template <typename A> const std::string& payloadFingerprint(); // "2:8c38bd160c0cf832"
template <typename T> struct PayloadShapeTag; // core/payload_shape_tag.hpp
}payloadShapeString<A>() renders A's JSON shape as a compact string — one
tag per member, key-sorted, recursing into reflected aggregates.
payloadFingerprint<A>() is the FNV-1a digest of that rendering, prefixed with
the scheme version so a future build that computes fingerprints differently can
tell a scheme change from a payload change. Both are memoised per type in a
function-local static.
Every tag is derived from a std:: type trait, from Glaze's reflected key
strings, or from a name spelled in this repository's own sources — never from a
compiler-spelled type name — so builds of the same sources on different
compilers, standard libraries, or platforms agree on the digest. A type with a
custom Glaze codec has no reflected members to decompose and declares its own
name by specialising PayloadShapeTag (core/payload_shape_tag.hpp); one that
declares nothing renders as the opaque x. The grammar, the deliberate
order-insensitivity, and the full list of what the fingerprint cannot see are
documented once, in
journal.md, where the
consequences live.
toJson/resultToJson write with detail::EscapingWriteOpts, a glz::opts
refinement that turns on glaze's escape_control_characters — the same
treatment, and for the same two reasons, that wire::encode already applies
to the envelope (see wire.md, "Control bytes in string fields"). With the
option off, an ASCII control byte (U+0000–U+001F) in any caller-supplied
string field of an action or result:
- produces invalid output — RFC 8259 requires those code points to be
escaped, and glaze's own reader enforces it, so the peer's
fromJsonthrows aParseErroron a body its own peer just wrote; and - can be silently corrupted — with a
\or"earlier in the same string, glaze's chunked fast path writes such a byte out as two0x00bytes, and the result still decodes.
Action bodies are pure caller data (a paste's content, a chat message, a
filename), so this is at least as exposed as the envelope was. Escaping is
lossless in both directions; the read side needs no counterpart, since glaze's
reader already accepts \uXXXX.
morph::model::detail::EscapingWriteOpts deliberately duplicates
morph::wire::detail::EscapingWriteOpts rather than reusing it: the action
codec belongs to the model layer and must not acquire a dependency on the
transport layer's header to share a four-line option struct.
Decides whether an in-progress action draft is ready to execute. Resolution order (highest priority first):
- Explicit specialisation — via
BRIDGE_REGISTER_VALIDATOR(Action, fn). bool validate() constmember onAction— auto-detected via thedetail::HasValidateconcept.- Default — returns
true(one-shot semantics: firstset<>lands and the action fires).
Validation is a property of the action, not the model: different actions on the same model have different readiness requirements.
template <typename Action>
struct ActionValidator {
static constexpr bool ready(const Action& action);
};A common validate() body composes morph::forms::allRulesSatisfied(*this)
(an action's declared cross-field rules, forms.md) with
morph::forms::allRequiredEngaged(*this) (per-field required-ness). Neither
requires any change to ActionValidator/HasValidate — both are ordinary
bool-returning calls inside validate(), picked up the same way any other
validate() body is.
Thrown by the two execution sites an action can reach ungated:
ActionDispatcher::registerAction's runner (the server dispatch path
RemoteServer uses on every remote and Qt WebSocket topology), which a
hand-built envelope from an untrusted remote client reaches directly, and
Bridge::executeVia's localOp (the in-process path LocalBackend uses),
which an action built by hand and handed to BridgeHandler::execute<Action>()
reaches directly. An action that already passed
morph::flows::FlowSession::set<>'s gate or the type-erased executeJson
gate still reaches whichever of the two its topology dispatches through, and
is simply re-checked there. Both call ActionValidator<Action>::ready(action)
immediately before Model::execute and throw ValidationError on false,
instead of executing the action:
struct ValidationError : std::runtime_error {
ValidationError(std::string_view modelType, std::string_view actionType);
// what(): "action failed validation: <modelType>/<actionType>"
};ActionDispatcher::registerAction's runner additionally reconciles every
Quantity field of the decoded action to its declared precision
(morph::forms::reconcileDeclaredPrecision, an exact re-rounding of the value
and not just a retag) before the ready() check, so a hand-built wire payload's
Quantity values match the schema's advertised x-decimalPlaces the same way
the client bridge dispatch path already normalises them (see
forms.md). Immediately after
reconciliation, morph::forms::enforceQuantityBounds rejects any Quantity
field whose engaged value falls outside its unit's declared bounds
(UnitTraits<E>::bounds), throwing QuantityDecodeError before the ready()
check — a no-op for actions with no Quantity members, or whose units
declare no bounds() (see forms.md, "Pre-decode wire
validation"). Bridge::executeVia's localOp does not reconcile precision or
enforce bounds — that path never decodes JSON, so there is no wire dp or
wire value to check against.
ValidationError derives from std::runtime_error, so it is caught by
existing generic catch (const std::exception&) handling on both paths
without any special-casing: LocalBackend::execute's strand catch (...)
(backend.hpp) forwards it into the Completion's onError with the concrete
type intact; RemoteServer::dispatchExecute's strand catch (const std::exception&) (remote.hpp) turns it into an ordinary err reply carrying
exc.what() and the callId — the client's Completion resolves through
onError with a generic std::runtime_error carrying that message (the
concrete type does not cross the wire).
Actions with no validator are unaffected on both paths: ActionValidator<A>::ready
defaults to true when neither a bool validate() const member nor a
BRIDGE_REGISTER_VALIDATOR specialisation exists, so this is backward
compatible.
ValidationError is not an authorization mechanism — see
security.md for that separate concern.
A strong enum avoiding bare bool arguments at registration sites:
enum class Loggable : std::uint8_t { No, Yes };Controls how repeated executions are checkpointed into a durable action log.
Only coalesce exists; every other action defaults to false (every execution
treated as a distinct fact).
template <typename Action>
struct ActionLogPolicy {
static constexpr bool coalesce = false;
};When coalesce is true, a checkpoint keeps only the most recent entry per
(modelType, entityKey, actionType) triple.
morph::wire carries an execute envelope's body as an opaque std::string
and never parses it — wire.hpp states this directly ("payload smuggled
inside body is invisible to any structural/depth check"). So
ActionTraits<A>::fromJson is the first and only place the body's contents are
decoded into typed fields, which makes it the layer responsible for what a
malformed payload means.
That matters for values whose decode cannot fail.
morph::math::Rational is the case in point: setWire
clamps what it cannot represent rather than rejecting, so
{"num":5,"den":0,"dp":2} would otherwise arrive as a perfectly plausible
5/1. A model's own validate() runs after the decode and has nothing left
to notice — the value looks fine by then.
fromJson therefore wraps its glz::read in a morph::math::WireClampScope
and throws ParseError if anything was clamped. Rational reports the fact;
this layer decides it is a protocol violation, because this is the layer that
knows the bytes came off a wire. A local caller constructing the same value in
code is unaffected.
Note that a non-canonical but representable value is accepted: 4/8 reduces
to 1/2, and reduction is canonicalisation, not clamping — the value survives
intact.
Type-erased wrapper that owns a single model instance. Used by backends to store
heterogeneous models in a single map. Declared in morph::model::detail (an
implementation type — backends hold it, application code never names it).
// namespace morph::model::detail
struct IModelHolder {
virtual ~IModelHolder() = default;
[[nodiscard]] virtual std::type_index type() const noexcept = 0;
[[nodiscard]] virtual bool isBackendChangeAware() const noexcept = 0;
virtual void onBackendChanged() {}
template <typename Model> Model& into();
void attachActionLog(std::shared_ptr<::morph::journal::IActionLog>, std::string contextKey);
bool hasActionLog() const noexcept;
void attachIdentity(std::string primaryKey);
void recordIfAttached(LogEntry entry);
void setOutboxManaged(bool outboxManaged) noexcept;
[[nodiscard]] bool isOutboxManaged() const noexcept;
};isBackendChangeAware()/onBackendChanged()are the compile-time-known backend-change-notification capability, exposed as base-class virtuals so a backend can query and invoke it withoutdynamic_cast.ModelHolder<M>answersisBackendChangeAware()from theBackendChangedNotifiable<M>concept and forwardsonBackendChanged()toM::onBackendChanged()only when that concept holds; the base default is a no-op. See backend.md for howLocalBackenduses this.into<Model>()down-casts to a concreteModel&; throwsstd::bad_caston mismatch.attachActionLogsets the durable log sink and the instance's stable identity (stamped onto everyLogEntry), then calls the protected virtualonActionLogAttached(log, contextKey)(base default: no-op) before storing either.ModelHolder<Model>overrides this to forward toModel::attachActionLog(log, contextKey)whenModelstructurally satisfiesModelLevelActionLogAttachable(morph/core/model.hpp) — the same "detect the hook structurally, forward only if present" shapeonBackendChanged()/BackendChangedMixinuse below. This is what lets a model that keeps its own model-levelIActionLogreference (to read its own history back later, e.g. an activity-stream view) receive the same log instance a registry-constructed, remote/keyed attach populates the holder with — see journal.md's "Attaching a log to remote instances". A model with noattachActionLogof its own is unaffected: the hook resolves to the base's no-op body for it.attachIdentity(primaryKey)tells this instance its own stable primary key, once, at construction time — the registry-constructed counterpart of a model re-deriving its key from every keyed action's own payload. A no-op ifprimaryKeyis empty. Otherwise forwards to the wrapped model's ownattachIdentity(primaryKey)when it declares one matchingModelIdentityAttachable(morph/core/model.hpp) — the same "detect the hook structurally, forward only if present" shapeattachActionLog/ModelLevelActionLogAttachableuse above; a no-op otherwise. Called byModelRegistryFactory::create(modelId, primary)immediately after construction — see that method below. Independent ofattachActionLog/_contextKey:contextKeyis the action log's entity-key field,primaryKeyis the shared-instance directory key (see backend.md'sBindRequest, which carriescontextKeyandprimaryas separate named fields, for why the two are kept as separate named fields rather than conflated); a caller wanting both calls both.recordIfAttachedis called automatically byActionDispatcher's runner andBridge::executeVia— model code never calls it directly. It fillsentityKey,principal(fromsession::current()), andtimestampMson the entry before forwarding. It is also a no-op whenisOutboxManaged()istrue— see journal.md's transactional outbox section.setOutboxManaged(true)marks this instance as managing its own outbox log write, sorecordIfAttachedstops auto-appending for it;hasActionLog()is unaffected. Defaults tofalse.
Concrete holder that stores a Model by value. Inherits BackendChangedMixin
so that backend-change notifications are forwarded automatically when Model
declares void onBackendChanged().
template <typename Model>
struct ModelHolder : IModelHolder, BackendChangedMixin<Model> {
Model model;
template <typename... Args> explicit ModelHolder(Args&&... args);
std::type_index type() const noexcept override;
bool isBackendChangeAware() const noexcept override;
void onBackendChanged() override;
protected:
void onActionLogAttached(const std::shared_ptr<::morph::journal::IActionLog>&,
const std::string& contextKey) override;
void onIdentityAttached(const std::string& primaryKey) override;
};Creates default-constructed ModelHolder<Model> instances. If a process-wide
default action log is installed (via morph::journal::setActionLog), it is
attached to the new holder automatically (with an empty entityKey). This is the
single construction path behind every ordinary model registration, making "set
the log once in main()" work uniformly across topologies.
class ModelFactory {
template <typename Model>
static std::unique_ptr<IModelHolder> create();
};Optional interface for models that need to react to backend switches.
ModelHolder<M> inherits BackendChangedMixin<M> which conditionally derives
from IBackendChangedSink when M declares void onBackendChanged() (detected
by the BackendChangedNotifiable<M> concept) — this remains available to
anyone holding an IModelHolder* who wants to dynamic_cast to it directly.
LocalBackend, however, does not: it discovers and invokes the same capability
through IModelHolder::isBackendChangeAware() / IModelHolder::onBackendChanged()
— two base-class virtuals ModelHolder<M> answers from the same
BackendChangedNotifiable<M> concept — so its notifyBackendChanged() sweep
needs no RTTI and visits only models that opted in. See
backend.md.
ActionDispatcher and ModelRegistryFactory are both declared in
registry.hpp in namespace morph::model::detail. ActionExecuteRegistry lives
elsewhere — see its section below.
Maps (modelId, actionId) pairs to type-erased runner functions. Used by
RemoteServer to dispatch incoming JSON requests.
One map, looked up by view. Everything registered under a pair lives in one
ActionEntry record — runner, coalesce, schema, describe — in a single
unordered_map<pair<string, string>, ActionEntry, PairKeyHash, PairKeyEqual>.
Two things follow:
- No key is built to look one up.
PairKeyHashandPairKeyEqualare transparent, sofindtakes adetail::PairKeyView— a pair ofstring_views — directly. Constructing the stored key instead costs twostd::strings per lookup, and reaches the heap for any id past the 15-character SSO buffer. Measured withmorph_bench_alloc(clang 22.1.8 / libstdc++ 16.2.1), stored key against transparent lookup: for a pair whose ids both exceed the buffer, 2.00 → 0.00 allocations per lookup; for a pair whose ids both fit it, 0.00 → 0.00. morph's real ids straddle that boundary, so the saving is real but id-dependent:"BenchAlloc_Model"(16 characters) allocates,"BenchAlloc_Ping"(15) does not. - The four sub-maps cannot go out of step. They were filled together by
registerActionand could not diverge in practice, but nothing said so.RemoteServer::handlealso reached three of them per request; that is now three lookups of one map rather than three lookups of three.
PairKeyHash's two overloads both reduce to the string_view body. They are
not allowed merely to agree by coincidence: a heterogeneous find whose lookup
hash disagreed with its stored hash would miss the bucket and report a
registered action as unknown, silently.
class ActionDispatcher {
using Runner = std::function<std::string(IModelHolder&, std::string_view)>;
template <typename Model, typename Action>
void registerAction(std::string_view modelId, std::string_view actionId);
std::string dispatch(std::string_view modelId, std::string_view actionId,
IModelHolder& holder, std::string_view payload);
bool coalesce(std::string_view modelId, std::string_view actionId) const;
std::string schemaFor(std::string_view modelId, std::string_view actionId) const;
std::string schemasJson(std::string_view modelId) const;
const std::vector<std::string>* requiredFieldsFor(std::string_view modelId,
std::string_view actionId) const;
static ActionDispatcher& instance();
};registerActionregisters a runner that deserialises, reconciles anyQuantityfields to their declared precision, rejects anyQuantityfield outside its unit's declared bounds (morph::forms::enforceQuantityBounds, throwingQuantityDecodeError; a no-op for actions with noQuantitymembers or whose units declare nobounds()), overwrites any declared computed fields from their inputs (morph::forms::recomputeAll, forms.md — a no-op for actions with nocomputedFields; runs after precision reconciliation and bounds enforcement and before the validator check, so the validator sees the authoritative computed value), enforcesActionValidator<Action>::ready(action)(throwingValidationErroronfalse, beforeModel::executeruns), then callsModel::execute(action)inside atry/catch (const std::exception&): on a throw it recordsoutcome = Outcome::Failed(error = exc.what(),resultempty) when the action is loggable and a log is attached, and rethrows unchanged, so callers see the same exception as before — the journal entry is a side effect, not a change to error propagation.Model::executeis the only call inside thattry: serialising the result and recordingoutcome = Outcome::Succeededrun after it, outside, because by then the mutation has committed and a throw from either is not an execution failure. Both surface asmorph::model::ActionRecordingErrorinstead — see journal.md, "A refused recording is not an execution failure". MirrorsBridge::executeVia'slocalOp(bridge.md) forLocalBackend. See journal.md, "Outcome" for the full field/replay semantics. Every recorded entry — success or failure — is stamped withdetail::actionPayloadSchema<Action>()inLogEntry::schema, and the same value is filed under(modelId, actionId)forschemaFor().dispatchlooks up the runner and invokes it; throwsstd::runtime_errorfor unknown pairs.coalescereturns theActionLogPolicy<Action>::coalescevalue for the pair; unknown pairs default tofalse.schemaForreturns the payload fingerprint the pair was registered with, or the empty string for an unregistered pair (or one whoseActionTraitsis hand-written and supplies nopayloadSchema()). This is the type-erased half of the journal's payload-evolution check:registerActionknows the concreteActionand can compute the fingerprint, whilejournal::replay()sees only the strings on aLogEntryand has to ask for it by id. The empty return is not an error — an entry naming an unregistered action fails atdispatch()with "unknown action" a moment later, which is the better diagnostic for that case.schemasJsonreturns the{actionType: schema}document for every action registered undermodelId, action ids emitted in sorted order so the output is byte-identical across calls and across servers built from the same sources.{}for a model type with no registered actions. This is whatRemoteServerserves for the"schemas"envelope kind — see wire.md, "Serving action schemas".requiredFieldsForreturns the wire names the pair's served schema lists inrequired, ornullptr.nullptrmeans nothing to check (the pair is unregistered, or its schema could not be generated), never nothing is required. Read byRemoteServer's opt-inPayloadCompleteness::RequireDeclaredFieldsgate, so the rule the server enforces is by construction the rule the schema published.
Alongside the runner, registerAction files a thunk returning A's
ActionDescription:
struct ActionDescription {
std::string schema; // forms::schemaJson<A>() + two x- keys
std::vector<std::string> required; // read back out of that schema
};schema is morph::forms::schemaJson<A>() with x-payloadFingerprint
(payloadFingerprint<A>()) and x-payloadShape (payloadShapeString<A>())
merged in over a glz::generic_u64 DOM — u64 number mode for the same
reason mergeSchemaExtras uses it, so int64/uint64 bounds in $defs
survive the round trip exactly. On a DOM read failure the raw schema text is
served unannotated and required stays empty: a description facility degrades,
it does not throw.
required is read back out of the served document rather than recomputed,
so a field the completeness gate insists on is exactly a field the schema told
the client about.
Three deliberate properties:
- A thunk, not the value. Registration runs at static-init time, where
forms::schemaJson<A>()'sUnsatisfiableFormErrorthrow path would abort the process beforemainrather than surface as anerrreply. - Cached in a function-local
static(actionDescription<A>()), likeforms::schemaJson<A>()'s own cache:RemoteServermay answer"schemas"on any pool thread, and the mapActionDispatcherfills is read-only by then, so the one piece of mutable state involved is the one C++ already guarantees is initialised exactly once. A throw leaves it uninitialised and a later call retries. - Not built for a hand-written
ActionTraits' benefit. The description describes the reflected struct, exactly asforms::schemaJsonalways has; an action whose codec maps to different JSON is described by what its struct reflects, the same caveatpayloadFingerprintcarries.
Creates IModelHolder instances by string type-id. Used by RemoteServer to
instantiate models on demand from incoming "register" messages.
class ModelRegistryFactory {
template <typename Model>
void registerModel(std::string_view modelId);
template <typename Model, typename Factory>
requires std::invocable<Factory> &&
std::convertible_to<std::invoke_result_t<Factory>, std::unique_ptr<IModelHolder>>
void registerModel(std::string_view modelId, Factory factory);
std::unique_ptr<IModelHolder> create(std::string_view modelId, std::string_view primary = {});
static ModelRegistryFactory& instance();
};createthrowsstd::runtime_errorfor unknown model types. The optional @p primary parameter is the instance's stable primary key (empty for an anonymous instance); after building the holder,createcallsholder->attachIdentity(primary)on it, which reaches the wrapped model's ownattachIdentitywhen it declares one (seeIModelHolder::attachIdentityabove) — a no-op for a model that doesn't, and a no-op entirely when @p primary is empty, so every existing single-argument call site is unaffected. This is what letsRemoteServer's two construction call sites (acquireSharedInstance's directory-miss branch, and the plainregisterbranch — both already haveenv.primaryin scope) tell a keyed model its own key once, at construction, instead of the model re-deriving it from every action's own payload.- The single-argument
registerModel<Model>(modelId)registers the plain default-construction path — equivalent toregisterModel<Model>(modelId, [] { return ModelFactory::create<Model>(); })— and is whatBRIDGE_REGISTER_MODELalways uses. - The two-argument overload is the per-instance dependency-injection seam
for registry-constructed (
Socket-mode) models — the equivalent, forRemoteServer's registry path, ofBridge::HandlerBinding::modelFactoryfor the client-sideLocal-mode path.factoryruns once percreate(modelId)call (i.e. once per incoming"register"request, and once per fresh shared-instance creation — seeacquireSharedInstance,remote.hpp), and may capture and hand the model constructor arbitrary per-instance dependencies an ordinary default constructor cannot reach: an injectable clock (seedocs/spec/util/datetime.md'snow()override seam for the complementary, constructor-free path to the same goal), a secondary log handle, a feature flag. It is also the only way to register a model whose constructor takes arguments at all — such a model has no accessible default constructor, so the single-argument overload cannot compile against it (see Remotely instantiated models must be default-constructible — unless registered with a custom factory).factoryreturns an owning pointer convertible tostd::unique_ptr<IModelHolder>(e.g.std::make_unique<ModelHolder<Model>>(...)) — the caller controls construction end-to-end, including whichModelHolder<Model>constructor overload runs. Unlike the default-construction overload, this one does not auto-attach the process-wide default action log (morph::journal::defaultActionLog()); a caller supplying its own factory is assumed to attach whatever log/identity it needs inside the closure viaIModelHolder::attachActionLog, or to rely onRemoteServer'sLogProviderdoing so afterward, exactly as the default path already allows. Two registrations for the samemodelIdstill silently last-write-wins, as for the single-argument overload (see Failure modes) — registering a custom factory under an id that aBRIDGE_REGISTER_MODEL(Model, id)invocation already claimed overwrites that default-construction factory, and vice versa, whichever static-init/runtime call happens last.
ModelRegistryFactory maps a string type-id to a fresh holder; it has no
notion of a per-instance id or owner. The numeric instance id that
addresses a live holder is assigned separately by RemoteServer from a single
sequential counter (_nextId), and those ids are therefore guessable across
tenants. To keep a caller from execute/deregister-ing an instance it did not
create, RemoteServer records an owner principal for each instance at
register time — the verified identity of the register call
(IAuthorizer::authenticate), not the client's raw claim — and consults the
optional IAuthorizer::authorizeInstance(ctx, modelType, actionType, modelId, ownerPrincipal) hook on every execute and deregister. The hook defaults
to allow, so this registry's type-keyed behaviour is unchanged unless a
deployer installs an authorizer that overrides it. The type registry maps type
ids only; instance ownership lives one layer up in RemoteServer. See
session.md and security.md.
Type-erased, JSON-in/JSON-out execute path for actions whose concrete C++ type
is only known by its registered string id at the call site (e.g. a schema-driven
GUI). Populated automatically by BRIDGE_REGISTER_ACTION. Every entry calls
through the real BridgeHandler<Model>::execute<Action>(), so sessions, backend
switches, and completions behave exactly as for hand-written call sites.
Unlike ActionDispatcher and ModelRegistryFactory (which live in
morph::model::detail in registry.hpp), the whole ActionExecuteRegistry
class is declared in morph/core/bridge.hpp in namespace morph::bridge — it depends
on BridgeHandler, which registry.hpp cannot see. Completion here is
morph::async::Completion.
class ActionExecuteRegistry { // namespace morph::bridge, declared in bridge.hpp
using Executor = std::function<::morph::async::Completion<std::string>(void*, std::string_view)>;
template <typename Model, typename Action>
void registerAction(std::string_view modelId, std::string_view actionId);
template <typename Sharing>
[[nodiscard]] ::morph::async::Completion<std::string> execute(
std::string_view modelId, std::string_view actionId,
void* handler, std::string_view bodyJson) const;
static ActionExecuteRegistry& instance();
};registerActionis only declared in the class body; its definition is out-of-line inbridge.hpp(afterBridgeHandleris fully defined) so the executor can safely cast thevoid*handler and call its methods.executeandinstance()are defined inline inbridge.hpp.executethrowsstd::runtime_errorfor unknown keys.
Unlike the other two registries, this one is not keyed on the string id
pair alone. Its map key is a three-field record — modelId, actionId, and a
std::type_index naming the sharing policy of the handler the executor will
be handed — and execute is a template on that policy, so a call site names
which of the entries it wants by naming its own Sharing type. KeyHash
mixes the type_index's hash_code() into the same PairKeyHash the other
registries use over the two strings.
The reason is that the executor is the one place in the framework that
recovers a typed handler from a void*. BridgeHandler<Model, Sharing> is a
class template over its sharing policy (morph::bridge::NoSharing or
morph::bridge::AllowShared — shared_instances.md),
and the two instantiations are unrelated types. An executor built for one
static_casts to that one; handed a handler of the other, it would produce a
pointer to the wrong type, and the handler's kShared — the compile-time
constant that decides whether a payload- or result-keyed action performs its
attach-or-promote step — would answer for the wrong instantiation. The
observable failure is silent: a shared handler's keyed action would simply
skip the step that gives it an instance, with no diagnostic anywhere. Keying
on the policy makes the mismatch unrepresentable instead of merely unlikely,
which is the only defence available when the type has already been erased.
registerAction therefore files two entries per registered action, one per
sharing tag the framework defines, both built from a single generic-lambda
template so their bodies cannot drift apart. The cost is one extra closure per
registered action — paid once at static-init time, not per call.
A consequence worth stating: because the two entries are enumerated by
registerAction rather than created on demand, a BridgeHandler instantiated
on some third, user-defined sharing tag has no entry at all. Its
executeJson throws "unknown action for executeJson: …" for an action that
is perfectly well registered. The sharing tags are a closed set of two by
construction; nothing in the type system says so (see
Limitations).
Three detail functions serve as static-init helpers that the macros call:
| Function | Purpose |
|---|---|
registerModelOnce<Model>(modelId) |
Registers a model factory with ModelRegistryFactory::instance(). Returns true so it can be assigned to a const bool in an anonymous namespace. |
registerActionOnce<Model, Action>(modelId, actionId) |
Registers a runner with ActionDispatcher::instance(). Returns true. |
registerActionExecutorOnce<Model, Action>(modelId, actionId) |
Registers with ActionExecuteRegistry::instance(). Only declared in registry.hpp; defined in bridge.hpp to avoid a registry.hpp → bridge.hpp include cycle. |
The process-level singletons are returned by defaultDispatcher() and
defaultRegistry() (both are inline functions with function-local static
variables).
All three helpers assert in a debug build that the
registration-phase latch is still open, which
is what turns the thread-safety precondition below from advice into a
diagnosable failure.
Each of the three builds std::string keys and grows a map, so each can throw
std::bad_alloc through a noexcept boundary and call std::terminate. That
is recorded in Failure modes and is deliberate, not an
oversight.
The only caller of any of them is the initialiser of a namespace-scope
const bool that BRIDGE_REGISTER_MODEL / BRIDGE_REGISTER_ACTION emit. An
exception that escapes the dynamic initialisation of a non-local variable
already calls std::terminate — [basic.start.dynamic] and
[except.terminate]. So dropping noexcept would not change what an
out-of-memory during static init does to the process; it would move the
terminate one frame outwards and add an unwind path to every registrar that
can never be taken. The noexcept states what the call site already
guarantees.
The assertion is the other half of that argument: it is what enforces that the call site really is the only one. Before it, "the only caller is a static initialiser" was an assumption with nothing behind it.
Both macros below name their generated anonymous-namespace variable by pasting
a fixed prefix onto __COUNTER__ (via the two-level BRIDGE_DETAIL_CAT/
BRIDGE_DETAIL_CAT_ indirection needed to force macro expansion before the
paste), not onto the spelling of M/A. Pasting the type directly (the
original approach) breaks for namespace-qualified or template types —
app::models::Report pastes :: into the identifier. __LINE__ was tried as
a replacement key but is only unique within a single physical file; two
different headers that each invoke one of these macros on the same line
number produce the same identifier once both are transitively #included
into one translation unit, which is a hard redefinition error because C++
unnamed namespaces are per-TU, not per-file. __COUNTER__ increments
monotonically across the whole translation unit regardless of which file
expands it, so it cannot collide this way.
Specialises ModelTraits<M> and registers a factory at static-init time.
BRIDGE_REGISTER_MODEL(AccountModel, "Account")Expands to:
template <> struct morph::model::ModelTraits<M> { static constexpr std::string_view typeId() noexcept { return NAME; } };- Unless
MORPH_CLIENT_ONLYis defined: a[[maybe_unused]] const boolin an anonymous namespace (internal linkage, no explicitstatic) that callsdetail::registerModelOnce<M>(NAME). SeeMORPH_CLIENT_ONLY.
Variadic macro accepting 3 or 4 arguments. The 4-argument form accepts an
optional Loggable value (defaults to Loggable::Yes).
BRIDGE_REGISTER_ACTION(AccountModel, Deposit, "Deposit")
BRIDGE_REGISTER_ACTION(AccountModel, GetAccount, "GetAccount", morph::model::Loggable::No)Expands to:
template <> struct morph::model::ActionTraits<A>withResultdeduced fromdecltype(std::declval<M&>().execute(std::declval<A>())), astatic constexpr std::string_view typeId()(nonoexcept, unlikeModelTraits::typeId()), astatic constexpr Loggable loggable, and four JSON codec functions (each throwingdetail::ParseErroron failure):toJson/resultToJsonuseglz::write<detail::EscapingWriteOpts{}>(see "Control bytes in action and result bodies");fromJson/resultFromJsonuseglz::read<glz::opts{.error_on_unknown_keys = false}>— the same forward-compatibility conventionwire::decodeuses (see wire.md, "Action-evolution policy") — so an older-compiled action struct silently ignores an additive field a newer peer sent.- Unless
MORPH_CLIENT_ONLYis defined: a[[maybe_unused]] const boolin an anonymous namespace callingdetail::registerActionOnce<M, A>(morph::model::ModelTraits<M>::typeId(), NAME)(the model-id argument is the model's registeredtypeId(), not a raw string). SeeMORPH_CLIENT_ONLY. - A
[[maybe_unused]] const boolin an anonymous namespace callingdetail::registerActionExecutorOnce<M, A>(morph::model::ModelTraits<M>::typeId(), NAME)— always emitted,MORPH_CLIENT_ONLYor not.
Hard requirement: Every translation unit invoking BRIDGE_REGISTER_ACTION
must include <morph/core/bridge.hpp> (directly or transitively) because
registerActionExecutorOnce is only defined there. Without it, the link fails
with an unresolved external symbol.
A client-side alternative, BRIDGE_REGISTER_ACTION_FOR_CLIENT, avoids the
Result-deduction step that forces M to be a complete type — see
BRIDGE_REGISTER_ACTION_FOR_CLIENT — a header seam for
MORPH_CLIENT_ONLY
below.
BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION's registrar initialiser is
not free to repeat per translation unit. Its right-hand side --
registerModelOnce<M>(...) / registerActionOnce<M, A>(...) -- is an
ordinary (non-template) function call written where the macro is expanded,
so its body is compiled there: registerActionOnce<M, A>'s call into
ActionDispatcher::registerAction<Model, Action> instantiates the runner
closure, which odr-uses ActionTraits<A>::toJson/fromJson/resultToJson/
resultFromJson (each a glaze codec over A) and files a describe thunk
over buildActionDescription<A> (forms::schemaJson<A>, another glaze
codec). None of that is triggered by the ActionTraits<A> specialisation
by itself -- a class's inline member functions are only compiled when
odr-used, and nothing odr-uses them until the registrar's initialiser calls
them. So a model header that many translation units #include pays for this
instantiation-and-optimisation work once per including TU, for codecs the
overwhelming majority of those TUs never call.
A prior measurement (clang 22.1.8, gcc 16.2.1, Linux, 12
cores, compiler cache off, best of two): stripping only
examples/kanban/include/kanban/models/board_model.hpp's 17
BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION lines cost 26.93 CPU-s at
-O3 -c and 2.63 CPU-s at -fsyntax-only, on the rung's own real compile
command -- the -fsyntax-only/-O3 gap showing the cost is
instantiate-and-optimise work, not parsing.
Re-measured for this fix (AppleClang 17.0.0, macOS, -O3 -c, a stub
translation unit that only #includes the header, best of two; not
isolated -- another build was running concurrently on this machine, so
CPU-seconds is reported rather than wall-clock, and the numbers below are a
lower bound, not a ceiling): the current header, using
BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION, costs 7.81 CPU-s;
restoring the pre-fix BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION content
(git show origin/master:.../board_model.hpp) costs 20.81 CPU-s -- a
13.00 CPU-s reduction per translation unit, on a different compiler and
machine than the original measurement but the same mechanism and the same
header. -fsyntax-only drops from 7.55 to 4.68 CPU-s (2.87 CPU-s), the same
proportionally-smaller gap the original measurement found. The compiled
object file corroborates it directly: the pre-fix stub's .o is
3,331,704 bytes (7155 symbols, 1,348,458 __TEXT bytes); the post-fix
stub's is 3,232 bytes (24 symbols, 172 __TEXT bytes) -- the registrar's
codecs, dispatch closures, and schema generator are simply absent from the
object file once the registration is deferred to board_model.cpp.
BRIDGE_DECLARE_MODEL(M, NAME) and BRIDGE_DECLARE_ACTION(M, A, NAME, ...)
emit only the trait specialisation half of what
BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION emit -- ModelTraits<M> /
ActionTraits<A> -- which stays free to repeat per TU exactly as it always
was (see "Header placement is legal" above).
BRIDGE_REGISTER_MODEL_SOURCE(M) and BRIDGE_REGISTER_ACTION_SOURCE(M, A)
emit the registrar half -- the same registerModelOnce/registerActionOnce/
registerActionExecutorOnce calls BRIDGE_REGISTER_MODEL/
BRIDGE_REGISTER_ACTION emit directly -- for a .cpp to call exactly once.
Neither NAME is repeated at the .cpp call site: both SOURCE macros read
it back off the trait specialisation DECLARE already installed
(ModelTraits<M>::typeId() / ActionTraits<A>::typeId()), so a DECLARE/
SOURCE pair cannot register under a different string than the one the
type's own traits report.
// board_model.hpp -- every translation unit that includes this pays only for
// the trait specialisation, not for the registrar's codec instantiation.
BRIDGE_DECLARE_MODEL(BoardModel, "BoardModel")
BRIDGE_DECLARE_ACTION(BoardModel, CreateSwimlane, "CreateSwimlane")
// board_model.cpp -- the one translation unit that pays the registration
// cost, once, for the whole program.
BRIDGE_REGISTER_MODEL_SOURCE(BoardModel)
BRIDGE_REGISTER_ACTION_SOURCE(BoardModel, CreateSwimlane)BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION are unchanged and remain the
right choice for a model whose registration cost is not worth splitting
across two macro calls in two files -- this is a decomposition of the
existing macros' expansion, not a replacement for them, and both shapes are
usable in the same program (see Failure modes's
last-write-wins entry if the same type is accidentally registered by both).
Splitting declaration from registration introduces a mistake that could not
previously happen: BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION in the
header with no matching BRIDGE_REGISTER_MODEL_SOURCE/
BRIDGE_REGISTER_ACTION_SOURCE anywhere in the program -- an omitted .cpp,
or a .cpp that exists but was never added to the target. Left unguarded,
this would reproduce exactly the failure mode MORPH_CLIENT_ONLY's own doc
comment names: the program compiles and links cleanly, and the first symptom
is ModelRegistryFactory::create's or ActionDispatcher::dispatch's runtime
"unknown model type" / "unknown action", far from the missing .cpp.
BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION close this at link time
instead. Each also emits a call to a function template --
detail::modelSourceRegistrationRequired<M>() /
detail::actionSourceRegistrationRequired<M, A>() -- behind an extern template declaration. extern template is what makes this work: it
suppresses this translation unit's own implicit instantiation of the
function (which has a trivial, always-visible definition) and instead
requires an explicit instantiation to exist somewhere else in the link.
BRIDGE_REGISTER_MODEL_SOURCE/BRIDGE_REGISTER_ACTION_SOURCE are the only
macros that provide one. So:
- Declared and registered (the normal case): the
.cpp's explicit instantiation satisfies every header'sextern templatereference. Link succeeds, exactly as before. - Declared, never registered: no explicit instantiation exists anywhere
in the link. Every translation unit that included the header carries an
unresolved reference to
modelSourceRegistrationRequired<M>/actionSourceRegistrationRequired<M, A>, and the final link fails with an unresolved external symbol naming the missing model/action by type -- before the program ever runs, let alone dispatches anything.
The function bodies do nothing; only whether an explicit instantiation
exists anywhere the linker looks is being tested. This is a stricter relative
of the "declare here, must be provided from over there" idiom
registerActionExecutorOnce already relies on (declared in registry.hpp,
generically defined in bridge.hpp -- see the "Hard requirement" note
above): that idiom only proves "this translation unit transitively included
bridge.hpp", since any TU that does gets a visible, callable generic
definition for any (Model, Action). The canary needs a stronger
guarantee -- "some translation unit in this exact link explicitly registered
this (Model, Action), not merely code that theoretically could" -- so it
pairs extern template (suppressing implicit instantiation from the header's
own, deliberately trivial, generic definition) with an explicit instantiation
only BRIDGE_REGISTER_MODEL_SOURCE/BRIDGE_REGISTER_ACTION_SOURCE emit,
rather than reusing registerActionExecutorOnce's plain
declare-in-one-header/define-in-another shape verbatim.
Confirmed empirically
(tests/compile_checks/declare_only_no_source_link.cpp and
declare_only_source_provided.cpp, run via the try_compile() block in
tests/CMakeLists.txt): a program that declares a real, fully-defined model
and action via BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION but never calls
either SOURCE macro fails to link with an unresolved symbol naming the
canary function; adding a second translation unit that does call both
SOURCE macros for the same model/action makes the same program link
cleanly (try_compile(), not try_run() -- link success is what the canary
claims, and is all this check exercises). Verified on Clang and GCC (both
link the missing-registration probe as "undefined reference"/"symbol(s) not
found"); not verified on MSVC, though
extern template is standard since C++11 and MSVC has supported it since
Visual Studio 2013.
Suppressed under MORPH_CLIENT_ONLY, exactly like the two registrars
BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION emit directly: a
MORPH_CLIENT_ONLY build legitimately never registers a model at all (see
MORPH_CLIENT_ONLY
below), so requiring an explicit instantiation there would turn every such
build's intended behaviour into a link failure.
What this does not close: dlopen/LoadLibrary plugins. The canary
requires the SOURCE macro's explicit instantiation to be part of the same
link as the header's extern template reference -- exactly the population
"Static initialisation only fires in linked translation
units"'s
dlopen case describes as running after main starts, i.e. never part of
the host executable's own link at all. A model meant to be registered from a
dynamically loaded module must therefore keep using the combined
BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION inside that module -- not
BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION with the registration deferred
to the host -- or the host executable simply fails to link at all, with
nothing in its own build able to satisfy the canary.
What the static-library case does -- untested, stated as inferred, not
measured. A registration-only .cpp built into a static-library member the
linker never pulls in (the other entry in that same section) previously
registered nothing and surfaced only as a runtime "unknown model type". The
canary's explicit instantiation lives in that same member, so every
BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION site now carries an extern template reference into it too -- but which of two outcomes that produces
depends on link order, and this has not been built and checked either way:
a linker that resolves a static archive by repeatedly rescanning it until no
more members are pulled in (the common case when the registering .cpp sits
in the same archive as its callers) would plausibly pull the member in on
the strength of the canary reference alone, which would fix registration
outright rather than merely fail loudly; one that scans archives once,
left to right, without --start-group, would instead leave the reference
unresolved and fail to link. Either outcome is better than the
pre-canary silent runtime failure. The
--whole-archive/-force_load//WHOLEARCHIVE guidance in that section
remains the guaranteed fix regardless of which applies.
Specialises ActionValidator<A> with a custom predicate.
BRIDGE_REGISTER_VALIDATOR(FormAction, [](const FormAction& a) {
return a.a != 0.0 && a.b != 0.0 && a.c != 0.0;
})Expands to template <> struct morph::model::ActionValidator<A> { static bool ready(const A& action) { return (FN)(action); } };.
A pure client — one that dispatches every action to a remote peer and never
constructs a model locally — has no use for two of the three registrars
BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION normally emit:
registerModelOnce<M>stores a factory ([] { return ModelFactory::create<M>(); }) in the process-levelModelRegistryFactory, used byLocalBackend/RemoteServerto construct a live instance.registerActionOnce<M, A>stores a runner in the process-levelActionDispatcherthat callsModel::execute(...)directly on a live holder — the server-side dispatch pathRemoteServeruses.
Both are ordinary functions the compiler must fully compile into the stored
closure regardless of whether that closure is ever invoked at runtime —
so even a build that never constructs a model locally still forces the
linker to resolve the model's constructor and execute() bodies, pulling in
whatever those depend on (a database driver, a native UI framework, an
OS-specific API) — dependencies a client target may have no link path for at
all (a browser/WASM build in particular), and will never call regardless.
Defining MORPH_CLIENT_ONLY (via the CMake option of the same name, which
adds it to the morph target's INTERFACE compile definitions) suppresses
both. BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION still specialise
ModelTraits<M>/ActionTraits<A> (type-ids, JSON codecs) exactly as before —
only the two registrar bodies above disappear.
The third registrar needed the same treatment, contrary to first
appearances. registerActionExecutorOnce<M, A> routes through
BridgeHandler<Model>::execute<Action>() → Bridge::executeVia<Model, Action>
— and executeVia unconditionally installs an ActionCall::localOp function
that calls Model::execute(...) directly, regardless of which backend ends
up installed at runtime (only LocalBackend::execute ever actually invokes
call.localOp; every remote backend ignores it). That function is compiled
into executeVia's instantiation the moment any code calls
BridgeHandler<Model>::execute<Action>() — which the type-erased
ActionExecuteRegistry executor registerActionExecutorOnce installs
also does, internally, to serve executeJson. So merely suppressing the
first two registrars is not sufficient to make a client-only build link if it
uses BridgeHandler::execute<Action>() (the typed API) or executeJson (the
type-erased API) at all — both routes reach the same model.execute(...)
call inside executeVia.
Bridge::executeVia's localOp is therefore itself gated on
MORPH_CLIENT_ONLY (bridge.hpp): under the macro, its body throws
std::logic_error instead of calling Model::execute, so nothing in the
compiled program ever references its definition. This is not a
per-registration-site choice like the two macros above — it lives inside
executeVia itself, compiled once per (Model, Action) instantiation,
consistently for the whole link (exactly the "carried on the interface, not
per-consumer" requirement below).
Confirmed empirically (see tests/compile_checks/client_only_no_model_link.cpp
and the try_compile() probes in tests/CMakeLists.txt): a model whose
constructor and execute() are declared but never defined anywhere in the
link succeeds when built with MORPH_CLIENT_ONLY defined, and fails to link
(both symbols genuinely referenced) when built without it.
Must be carried on the morph target's INTERFACE, never per-consumer.
The macro changes which registrars a model header emits; two translation
units disagreeing about it — one linking registerModelOnce's closure, the
other not — would be an ODR violation (the closures wouldn't even have the
same instantiated members). The MORPH_CLIENT_ONLY CMake option sets it via
target_compile_definitions(morph INTERFACE MORPH_CLIENT_ONLY), so every
consumer of the morph::morph target sees the identical definition.
Must never be defined for a process that hosts models. A server, or any
Bridge running LocalBackend, would silently register nothing:
ModelRegistryFactory::create/ActionDispatcher::dispatch would fail at
runtime with "unknown model type" — far from the actual cause — rather
than failing to compile or link. Bridge::executeVia's localOp closure
throwing std::logic_error if ever reached is a second line of defence for
exactly this mistake: a MORPH_CLIENT_ONLY build that somehow still ends up
running LocalBackend gets a clear, immediate diagnostic instead of a
"model not found" red herring.
Off by default: the standard build (and every existing consumer) is unaffected.
MORPH_CLIENT_ONLY removes the link dependency on a model's implementation
(above), but not the header dependency: BRIDGE_REGISTER_ACTION's Result
type is decltype(std::declval<M&>().execute(std::declval<A>())), so M must
be a complete type with execute(A) declared at the exact point the macro
is invoked — ordinarily the model's own header. A pure client that never
constructs M still has to #include that header (and everything it pulls
in transitively — a persistence mixin's database-driver headers, for a model
backed by one) purely to let this decltype resolve, even though a
MORPH_CLIENT_ONLY build never calls Model::execute at all (executeVia's
localOp throws instead, per the previous section). A WASM/browser client has
no include path for a native database client library at all, so this is a hard
build blocker, not merely extra compile weight.
BRIDGE_REGISTER_ACTION_FOR_CLIENT(M, A, RESULT, NAME, ...) closes this seam:
it emits the exact same ActionTraits<A> specialisation as
BRIDGE_REGISTER_ACTION, except Result is the explicitly-named @p RESULT
type argument instead of a decltype-deduced one. M is then used only as
BridgeHandler<M>'s template tag and ActionExecuteRegistry's dispatch key —
both routes call only ModelTraits<M>::typeId() (needs the trait
specialisation, not M's completeness) and, under MORPH_CLIENT_ONLY, never
reach holder.into<M>()/M::execute(...) (gated out inside executeVia, see
above) — so M may be forward-declared and never defined anywhere in the
client's link. A client's model header therefore reduces to one forward
declaration plus the two registration macros; the real, complete model
(inheriting whatever persistence mixin it needs) lives only in the
server-side translation unit that actually owns it.
// client_only_model.hpp -- the ENTIRE client-visible surface for RecordModel,
// under MORPH_CLIENT_ONLY. No database-driver header, no ORM mixin, in sight.
struct RecordModel; // forward declaration only -- never defined here
struct RecordMeasurement { /* ...fields... */ };
struct RecordMeasurementResult { /* ...fields... */ };
BRIDGE_REGISTER_MODEL(RecordModel, "RecordModel")
BRIDGE_REGISTER_ACTION_FOR_CLIENT(RecordModel, RecordMeasurement, RecordMeasurementResult, "RecordMeasurement")M being incomplete constrains which BridgeHandler<M> constructor a
client may use. BridgeHandler<M>'s default constructor
(BridgeHandler(Bridge&, IExecutor*)) calls Bridge::registerHandler<M>(),
which unconditionally builds [] { return ModelFactory::create<M>(); } — and
ModelFactory::create<M> default-constructs M by value, requiring M
complete. Using it here would silently reintroduce the exact completeness
requirement this macro exists to avoid. The client must instead use the
pre-built-binding constructor
(BridgeHandler(Bridge&, IExecutor*, shared_ptr<HandlerBinding>)) with a
modelFactory that is never actually invoked in a MORPH_CLIENT_ONLY process
(no LocalBackend exists to call it — see Bridge::executeVia's
MORPH_CLIENT_ONLY guard, above):
auto binding = std::make_shared<morph::bridge::detail::HandlerBinding>();
binding->typeId = std::string{morph::model::ModelTraits<RecordModel>::typeId()};
binding->modelFactory = [] -> std::unique_ptr<morph::model::detail::IModelHolder> {
throw std::logic_error("client-only: RecordModel has no local instance");
};
morph::bridge::BridgeHandler<RecordModel> handler{bridge, guiExec, std::move(binding)};
// Dispatch generically -- executeJson never touches RecordModel's definition.
handler.executeJson("RecordMeasurement", bodyJson);@p RESULT is not checked against the real model's execute return
type — this is the one thing the macro cannot verify. A mismatch is a
silent JSON-shape bug (the client (de)serialises the wrong shape on the wire),
not a compile error: nothing here compares against the server-side
registration, which still uses the plain BRIDGE_REGISTER_ACTION from the
real model's own header and therefore still deduces Result correctly from
Model::execute's actual return type. Keeping the two declarations in sync is
the caller's responsibility, same as any hand-written wire contract.
Usable with or without MORPH_CLIENT_ONLY defined — the emitted
ActionTraits<A> is identical either way — but the header-avoidance benefit
only materialises when M is genuinely left incomplete at the client's
registration site and MORPH_CLIENT_ONLY is defined (so
MORPH_DETAIL_REGISTER_MODEL_LOCAL/MORPH_DETAIL_REGISTER_ACTION_LOCAL's
registrars, which do need M complete, are suppressed for that build).
Confirmed empirically
(tests/compile_checks/client_only_facade_no_model_header.cpp, run via
try_run() in tests/CMakeLists.txt): ClientOnlyFacadeModel is
forward-declared and never defined anywhere in that probe's link; it compiles,
links, and round-trips ClientOnlyFacadeAction through toJson/fromJson
correctly under MORPH_CLIENT_ONLY.
| Symbol | Kind | Purpose |
|---|---|---|
ModelTraits<M> |
class template | Customisation point. Maps model type to std::string_view typeId(). |
ActionTraits<A> |
class template | Customisation point. Maps action type to id, JSON codec, result type, Loggable, and (macro-generated only) payloadSchema(). |
ActionValidator<A> |
class template | Customisation point. static bool ready(const A&) — built-in detection of bool validate() const, overridable via specialisation. |
ValidationError |
exception type | Thrown by ActionDispatcher::registerAction's runner and Bridge::executeVia's localOp when ActionValidator<A>::ready returns false. std::runtime_error subclass carrying "action failed validation: <modelType>/<actionType>". |
ActionLogPolicy<A> |
class template | Customisation point. static constexpr bool coalesce = false — checkpoint coalescing policy. |
payloadFingerprint<A>() |
function template | payload_schema.hpp. Structural fingerprint of A's JSON shape, "<scheme>:<16 hex digits>". Companion: payloadShapeString<A>(), the human-readable rendering behind it. |
Loggable |
enum | { No, Yes } — strong boolean for action loggability. |
| Symbol | Purpose |
|---|---|
HasValidate<A> |
true when A exposes bool validate() const. |
HasLoggableFlag<A> |
true when ActionTraits<A> exposes static constexpr Loggable loggable. |
HasPayloadSchema<A> |
true when ActionTraits<A> exposes payloadSchema(). Both registration macros generate one; a hand-written specialisation need not. |
BackendChangedNotifiable<M> |
true when M exposes void onBackendChanged(). |
| Symbol | Kind | Purpose |
|---|---|---|
IModelHolder |
abstract class | Type-erased model owner with an action log slot, an outbox-managed opt-out flag, and a compile-time-answered backend-change-awareness bit. |
ModelHolder<M> |
class template | Concrete holder storing M by value; conditionally inherits IBackendChangedSink; answers isBackendChangeAware()/onBackendChanged() from BackendChangedNotifiable<M>. |
ModelFactory |
class | static create<M>() — default-constructs ModelHolder<M> and attaches the process-wide default log. |
IBackendChangedSink |
abstract class | Optional interface for backend-switch notification; reachable via dynamic_cast, though LocalBackend itself dispatches through IModelHolder's virtuals instead. |
BackendChangedMixin<M> |
class template | Conditionally inherits IBackendChangedSink when M has onBackendChanged(). |
| Symbol | Kind | Purpose |
|---|---|---|
ActionDispatcher |
class | Maps (modelId, actionId) → one ActionEntry (runner, coalesce flag, payload fingerprint, ActionDescription thunk); server-side dispatch. Looked up by PairKeyView, so a lookup allocates nothing. |
ActionDescription |
struct | {schema, required} for one action: forms::schemaJson<A>() plus x-payloadFingerprint/x-payloadShape, and the required names read back out of it. |
actionDescription<A>() |
function template | The process-lifetime ActionDescription for A; buildActionDescription<A>() is the uncached builder behind it. |
ModelRegistryFactory |
class | Maps modelId → factory; server-side model instantiation. |
closeRegistrationPhase() |
function | Declares the registration phase over. From then on a debug build asserts on every register*Once call. See "The registration-phase latch". |
registrationPhaseClosed() |
function | Whether the registration phase has closed. |
ActionExecuteRegistry |
class | Maps (modelId, actionId, typeid(Sharing)) → type-erased executor through BridgeHandler; client/schema-driven execute. Two entries per action, one per sharing policy — see "The key is a triple". |
| Macro | Arguments | Generates |
|---|---|---|
BRIDGE_REGISTER_MODEL |
(M, NAME) |
ModelTraits<M> specialisation + static-init factory registration. |
BRIDGE_REGISTER_ACTION |
(M, A, NAME, ...) |
ActionTraits<A> specialisation + static-init dispatcher and executor registration. Optional 4th arg: Loggable. Result deduced from decltype(M::execute(A)), requiring M complete. |
BRIDGE_REGISTER_ACTION_FOR_CLIENT |
(M, A, RESULT, NAME, ...) |
Same as BRIDGE_REGISTER_ACTION, except Result is the explicitly-named RESULT argument — M may be forward-declared only. See "a header seam for MORPH_CLIENT_ONLY". |
BRIDGE_DECLARE_MODEL |
(M, NAME) |
ModelTraits<M> specialisation + a link-time canary requiring BRIDGE_REGISTER_MODEL_SOURCE(M) somewhere in the link. No registration. See "Moving a registrar out of the header". |
BRIDGE_REGISTER_MODEL_SOURCE |
(M) |
Static-init factory registration for a M already declared via BRIDGE_DECLARE_MODEL. Reads NAME back off ModelTraits<M>::typeId(). |
BRIDGE_DECLARE_ACTION |
(M, A, NAME, ...) |
ActionTraits<A> specialisation + a link-time canary requiring BRIDGE_REGISTER_ACTION_SOURCE(M, A) somewhere in the link. Optional 4th arg: Loggable. No registration. |
BRIDGE_REGISTER_ACTION_SOURCE |
(M, A) |
Static-init dispatcher and executor registration for an A already declared via BRIDGE_DECLARE_ACTION. Reads NAME back off ActionTraits<A>::typeId(). |
BRIDGE_REGISTER_VALIDATOR |
(A, FN) |
ActionValidator<A> specialisation + custom predicate. |
| Symbol | Purpose |
|---|---|
PairKeyView |
std::pair<std::string_view, std::string_view> — the type a (modelId, actionId) pair is looked up with. The stored key remains a pair of std::strings. |
PairKeyHash |
Transparent hash functor for (modelId, actionId) keys. Both overloads reduce to the string_view body, so lookup and stored hashes agree by construction. Used with PairKeyEqual by ActionDispatcher, and as the string half of ActionExecuteRegistry's own KeyHash, which mixes the sharing policy's type_index into it. |
PairKeyEqual |
Transparent equality functor for the same keys. unordered_map enables heterogeneous lookup only when the hash and the equality are both transparent. |
actionLoggable<A>() |
Returns ActionTraits<A>::loggable if present, else Loggable::Yes. |
actionPayloadSchema<A>() |
Returns ActionTraits<A>::payloadSchema() if present, else "" (unstamped). |
ParseError |
std::runtime_error subclass thrown on JSON codec failure. |
registerModelOnce<M>(id) |
Static-init helper; returns true. |
registerActionOnce<M, A>(modelId, actionId) |
Static-init helper; returns true. |
registerActionExecutorOnce<M, A>(modelId, actionId) |
Static-init helper; only declared in registry.hpp, defined in bridge.hpp. |
modelSourceRegistrationRequired<M>() |
Link-time canary: BRIDGE_DECLARE_MODEL calls it behind extern template; BRIDGE_REGISTER_MODEL_SOURCE is the only macro that explicitly instantiates it. See "The link-time canary". |
actionSourceRegistrationRequired<M, A>() |
Same canary, for BRIDGE_DECLARE_ACTION/BRIDGE_REGISTER_ACTION_SOURCE. |
registrationPhaseFlag() |
The process-wide registration-phase std::atomic<bool>. See "The registration-phase latch". |
noteRegistryRead(isProcessRegistry) |
Closes the latch on the first read of a process-level registry. Debug builds only; empty under NDEBUG. |
reopenRegistrationPhaseForTesting() |
Re-opens the latch. Test-only. |
| Decision | Choice | Why |
|---|---|---|
| Trait-based registration | Template specialisation + static-init guards | Users never manage registry lifecycle; a macro or a hand-written specialisation is all that's needed. Static-init guarantees registrations are live before any main() code runs. |
| Singleton registries | Function-local static in inline functions |
Process-level singletons with no header-level static ordering issues; inline avoids ODR violations across translation units. |
| Two registries for action execution | ActionDispatcher (server) vs ActionExecuteRegistry (client) |
ActionDispatcher calls Model::execute() directly on an owned IModelHolder — the server-side path. ActionExecuteRegistry goes through BridgeHandler<Model> — sessions, backend switches, and completions work identically to hand-written call sites. Both are populated by the same macro. |
registerActionExecutorOnce forward-declared in registry.hpp |
Defined in bridge.hpp |
Avoids a registry.hpp → bridge.hpp include cycle. bridge.hpp already includes registry.hpp. The cost: every translation unit that uses BRIDGE_REGISTER_ACTION must also include bridge.hpp or the link fails. |
HasLoggableFlag backward compatibility |
Defaults to Loggable::Yes when loggable is absent |
Hand-written ActionTraits specialisations in tests predate the member; forcing them to add it would be churn. The default of Yes also means new actions are captured automatically — only pure queries opt out. |
| Action validation is a property of the action | ActionValidator<Action>, not ActionValidator<Model, Action> |
Different actions on the same model have different readiness requirements; keeping the predicate next to the action keeps the GUI side oblivious to model internals. |
Loggable is a strong enum |
Loggable::No / Loggable::Yes, not bare bool |
Registration call sites read as intent rather than an unexplained false. |
ModelFactory::create attaches the default log |
Single construction path for all topologies | "Set the log once in main()" works uniformly across local and remote topologies. Callers that need a specific identity call attachActionLog again afterward. |
setOutboxManaged opt-out |
Suppress recordIfAttached, not hasActionLog() |
A store-backed model that logs inside its own transaction (see journal.md's transactional outbox) must stop the framework's auto-append without losing "a log is attached" as a fact holders can still query. |
coalesce defaults to false |
Every execution is a distinct, permanent fact | The right default for anything resembling a business event. Only actions where only the latest occurrence should survive a checkpoint (e.g. a form-field edit fired repeatedly via morph::flows::FlowSession::set) opt in. |
ActionDispatcher keeps one record per pair, not four maps |
ActionEntry in a single unordered_map |
The four sub-maps were keyed identically and filled by one function, so the lockstep was real but unstated; and RemoteServer::handle reached three of them per request. One record makes the invariant structural and the three reads one table each. |
Enforcing the "no registration after main" precondition |
A debug-build latch and an assert, not a mutex |
Documented alone the constraint is unenforceable, and a violation's only symptom is intermittent map corruption. A latch closed by the first singleton read costs nothing in a release build and turns the dlopen scenario into an abort with a message. Synchronising the registries instead would put a lock on a per-request read path to legalise a startup-only operation — a trade nobody has measured. |
register*Once stays noexcept |
Keep it, and record why | The only caller is a namespace-scope initialiser, where an escaping exception already calls std::terminate ([basic.start.dynamic]). Removing noexcept changes nothing about an OOM at static init and adds a dead unwind path. See "Why all three are noexcept while allocating". |
| Registry lookups are heterogeneous | Transparent PairKeyHash/PairKeyEqual, find(PairKeyView) |
Every caller already holds string_views; materialising the stored pair<string, string> to hash it allocated for any id past the SSO buffer, measured at 2 allocations per lookup. The transparent hash routes both overloads through one string_view body so lookup and stored hashes cannot drift apart — a drift that would report a registered action as unknown with no diagnostic. |
All three registries — ActionDispatcher, ModelRegistryFactory, and
ActionExecuteRegistry — are backed by plain std::unordered_map members with
no mutex, no atomic, and no other synchronisation. This is deliberate and
safe only because of the registration model:
- Writes happen during static initialisation, which runs single-threaded
before
main()(the process has not spawned worker threads yet). EveryregisterAction/registerModelmutation therefore happens-before any code that could observe the map concurrently. - After
main()begins the maps are read-only.dispatch,create, andcoalesceonly ever callfindon an already-populated map — concurrent reads of aconst-in-practiceunordered_mapare data-race-free. This is what letsRemoteServerand the bridge dispatch/create/coalesce from arbitrary threads without locking.
The corollary is a hard constraint: runtime registration is not thread-safe.
Calling registerAction / registerModel after threads are running — e.g. from
a dlopened module loaded on a worker thread while another thread is
dispatching — races the map's internals against concurrent find calls and is
undefined behaviour. Load and register plugin modules from a single thread,
quiesced with respect to dispatch, before exposing them.
Documented alone, the constraint above is unenforced: nothing would detect a
post-main registration, so a caller could violate it silently and discover it
as intermittent map corruption with no diagnostic anywhere.
registry.hpp now carries a one-way latch over the registration phase:
| Symbol | Kind | Behaviour |
|---|---|---|
morph::model::closeRegistrationPhase() |
public function | Closes the latch. Idempotent, thread-safe, never re-opens. Call it once early in main(), before spawning a thread. |
morph::model::registrationPhaseClosed() |
public function | Reports the latch state. |
detail::registrationPhaseFlag() |
detail function | The std::atomic<bool> itself: a function-local static in an inline function, one per process, for the same reason the registries are. |
detail::noteRegistryRead(bool isProcessRegistry) |
detail function | Debug builds only — closes the latch on the first read of a process-level registry. Empty under NDEBUG. |
detail::reopenRegistrationPhaseForTesting() |
detail function | Re-opens it. Test-only; an application that calls it has re-created the hazard the latch exists to catch. |
Three call sites feed the latch — ActionDispatcher::dispatch,
ModelRegistryFactory::create and ActionExecuteRegistry::execute — each
guarded on the instance being the process singleton. A locally constructed
ActionDispatcher (which several tests own, and which RemoteServer accepts
by reference) is outside the constraint and must not latch the whole program;
it does not.
Three asserts consume it: registerModelOnce, registerActionOnce and
registerActionExecutorOnce — which is exactly what a dlopened module's
BRIDGE_REGISTER_* initialisers call. A plugin loaded after dispatch has begun
therefore aborts on a debug build with a message naming this section, instead
of corrupting a map on a release build with no message at all.
Two asymmetries, both deliberate.
- Debug builds only. The assertion is an
assert, whichNDEBUGcompiles out, so the auto-close that feeds it is compiled out too:ActionDispatcher::dispatchis a per-request path and must not pay for a check nothing reads. A release build therefore answersregistrationPhaseClosed()withfalseuntil the application closes the latch itself.closeRegistrationPhase()andregistrationPhaseClosed()exist and work on both builds; only the automatic closing is conditional. - The latch is not synchronisation. It detects the violation; it does not make the violating call safe. The alternative — an actual mutex or concurrent map on the registries — is explicitly out of scope, because the read path is per-request on the server and no cost measurement has been taken for locking it.
The enforcement is proven rather than asserted, per AGENTS.md:
tests/test_registration_phase.cpp fork()s a child that closes the latch and
then calls each registrar, and requires the child to die on SIGABRT. Each of
the three is paired with a control that runs the same registrar with the latch
open and requires a normal exit — without it, a registrar that aborted for
some unrelated reason would look identical. Both halves were watched failing
against a mutated fix (the three asserts rewritten to assert(true && ...),
and separately noteRegistryRead emptied).
| Situation | Behaviour | Where |
|---|---|---|
Two registrations for the same (modelId, actionId) (or same modelId) |
Silent last-write-wins. ActionDispatcher::registerAction overwrites the whole ActionEntry under the key; ModelRegistryFactory::registerModel does insert_or_assign. No diagnostic; the surviving entry is whichever initialiser ran last, and static-init order across TUs is unspecified. |
registry.hpp |
| Two distinct C++ types registered under one string id | Same silent overwrite — the string id, not the type, is the key. The second type's runner/factory shadows the first. This is the collision hazard behind the string-vocabulary limitation below. | registry.hpp |
dispatch / execute with an unknown key |
Throws std::runtime_error at runtime — "unknown action: …" from ActionDispatcher::dispatch for an unknown (modelId, actionId), "unknown action for executeJson: …" from ActionExecuteRegistry::execute for an unknown (modelId, actionId, typeid(Sharing)) — which includes a registered action reached from a handler on a sharing tag other than NoSharing/AllowShared. The string-keyed remote path has no compile-time completeness check — a pair that was never registered is only discovered when a request for it arrives. |
ActionDispatcher::dispatch, ActionExecuteRegistry::execute |
dispatch when the decoded action fails ActionValidator<Action>::ready(...) |
Throws morph::model::ValidationError (a std::runtime_error subclass) before Model::execute runs — the action is never executed. Actions with no validator (the common case) are unaffected: ready() defaults to true. |
ActionDispatcher::registerAction's runner |
create with an unknown model id |
Throws std::runtime_error("unknown model type: …") at runtime. |
ModelRegistryFactory::create |
coalesce for an unknown pair |
Does not throw — defaults to false (every entry kept). |
ActionDispatcher::coalesce |
Allocation failure inside a register*Once helper during static init |
registerModelOnce / registerActionOnce (and registerActionExecutorOnce) are declared noexcept yet allocate (they build std::string keys and grow the map). An OOM there raises an exception through a noexcept boundary, which calls std::terminate — the process aborts during static init. This is the intended outcome rather than an accepted defect: the same OOM without noexcept terminates anyway, because the caller is the dynamic initialiser of a non-local variable. See "Why all three are noexcept while allocating". |
registry.hpp |
A register*Once call after the registration phase closes (e.g. a dlopened module registering once dispatch has begun) |
Debug build: the assert fires and the process aborts with a message naming this hazard. Release build: unchanged — undefined behaviour, racing the map's internals against concurrent finds, with no diagnostic. The latch detects the violation; it does not make it safe. |
registry.hpp, bridge.hpp — see "The registration-phase latch" |
BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION used with no matching BRIDGE_REGISTER_MODEL_SOURCE/BRIDGE_REGISTER_ACTION_SOURCE anywhere in the same link |
Fails to link — an unresolved external symbol naming modelSourceRegistrationRequired<M> / actionSourceRegistrationRequired<M, A>, before the program ever runs. Contrast the row above: this is the one registration mistake in this table that is not deferred to runtime. See "The link-time canary". |
registry.hpp |
Note the asymmetry the design accepts intentionally: the typed local path
(BridgeHandler::execute<Action>(), Model::execute(action)) is checked by the
compiler — an unregistered or misspelled action is a build error — whereas the
string-keyed remote/schema path through these registries defers every id
resolution to runtime. Registration correctness for the remote surface is a
testing obligation, not a compile-time guarantee.
- String type-ids are an unversioned, un-namespaced global protocol
vocabulary. Every
NAMEpassed toBRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTIONlives in one flat namespace shared across the whole process and, implicitly, across the wire with every peer. There is no version tag, no module qualifier, and — per Failure modes — collisions are silent last-write-wins. Two independently developed subsystems that both register"Update"will clobber each other with no diagnostic. Mitigations the design does not yet enforce but should be adopted by convention: an id-namespacing convention (e.g."bank.Account"/"bank.Account.Deposit"prefixes per subsystem) to make collisions structurally unlikely, and a startup self-check that iterates the intended(model, action)set and asserts each is present before serving traffic — there is otherwise no compile-time guarantee that every remotely executed pair was actually registered (registration is a static-init side effect that can be silently dropped; see Registration rules and invariants). A model/action registered viaBRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTIONplusBRIDGE_REGISTER_MODEL_SOURCE/BRIDGE_REGISTER_ACTION_SOURCEgets a link-time, not compile-time, version of this guarantee instead — see "The link-time canary" — but only for pairs that opt into the split macros; the combinedBRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTIONcarry no such check, because for them declaration and registration are the same macro call and cannot drift apart. - Global mutable singletons with no teardown or reset.
defaultDispatcher()anddefaultRegistry()(andActionExecuteRegistry::instance()) are function-localstatics that live for the whole process and expose no clear / reset. This hurts test isolation: registrations accumulate across test cases in one binary, one test cannot register a fake model without leaking it into the next, and last-write-wins means test ordering can change behaviour. Contrastjournal::ScopedActionLog, which deliberately provides scoped install/restore for exactly this reason; the registries have no equivalent. - Per-call heap allocation on the hot path — fixed for
ActionDispatcher, still open for the other two.ActionDispatcher's lookups are heterogeneous now (PairKeyHash/PairKeyEqualare transparent, andfindis given aPairKeyView), sodispatch,coalesce,schemaForandrequiredFieldsForbuild no key at all.ModelRegistryFactory::createandActionExecuteRegistry::executestill construct their keys to probe the map;ActionExecuteRegistry's key carries atype_indexas well as the two ids, so it is not the same change. - The
ActionDispatcher/ActionExecuteRegistrysplit can silently diverge. A singleBRIDGE_REGISTER_ACTIONpopulates both registries (one initialiser each). But they are independent maps consulted by different code paths —ActionDispatcheron theRemoteServerserver path,ActionExecuteRegistryon theBridgeHandlerschema-driven path. If only one registration fires (e.g. a hand-writtenActionTraitsspecialisation that registers a runner but skips the executor, or a partial refactor), one path works and the other throws "unknown action" for the same logical action, with no signal that the two are meant to stay in lockstep. - The sharing-policy half of
ActionExecuteRegistry's key is a closed set of two by convention, not by construction.registerActionenumeratesNoSharingandAllowSharedexplicitly, butBridgeHandler'sSharingtemplate parameter is unconstrained — nothing rejectsBridgeHandler<M, MyOwnTag>at compile time, and such a handler behaves asNoSharingeverywhere exceptexecuteJson, which throws "unknown action" for an action that is registered. A concept constrainingSharingto the two tags would turn that runtime surprise into a compile error; none exists today.
- bridge.md — defines
BridgeHandler<Model>,Bridge, and theActionExecuteRegistryclass itself (declared in<morph/core/bridge.hpp>, notregistry.hpp). Explains the hard#include <morph/core/bridge.hpp>requirement for any TU usingBRIDGE_REGISTER_ACTION(registerActionExecutorOnceis only defined there), the parallel executor path this spec'sActionExecuteRegistrysection summarises, andBridge::executeVia'slocalOp, which enforces the sameValidationErrorgate as this spec'sActionDispatcher::registerActionfor the local execution path and performs the same computed-field recompute (morph::forms::recomputeAll) for it. - forms.md —
allRequiredEngaged; the closed cross-field rule vocabulary (allRulesSatisfied,x-rules) that composes intovalidate()alongside it; andcomputed/computeList/recomputeAll, both invoked byActionDispatcher::registerAction's runner beforeModel::execute. - journal.md —
IActionLog,LogEntry,SessionLog, checkpoint coalescing, andScopedActionLog. Explains how the runner'srecordIfAttachedcall andActionLogPolicy<Action>::coalescefeed the durable log, and provides the scoped-install pattern the registries lack. AlsoLogEntry::idempotencyKeyandjournal::OutboxRelay, the transactional outbox this spec'ssetOutboxManaged/isOutboxManagedopt-out enables — see Transactional outbox (opt-in). - backend.md — backends store
IModelHolders in a single map and drive backend-change notification viaIModelHolder::isBackendChangeAware()/onBackendChanged()(in turn backed byBackendChangedMixin/IBackendChangedSink); the model instances created byModelRegistryFactoryland here. - security.md — the
session::current()principal stamped onto every logged entry byrecordIfAttached, and the trust boundary of the string-keyed remote dispatch surface. - Error handling — the
detail::ParseError/std::runtime_errortaxonomy the registries raise (see Failure modes); glaze codec errors originate in theActionTraitsJSON functions. - Concurrency and lifetimes — the static-init-then-read-only discipline in Thread safety and the process-lifetime singleton ownership in Limitations.