Skip to content

Latest commit

 

History

History
1611 lines (1396 loc) · 99.3 KB

File metadata and controls

1611 lines (1396 loc) · 99.3 KB

The model registration system — design

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.

Contents

Overview

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> and ActionTraits<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: the morph::flows::FlowSession::set<> gate, the type-erased executeJson path, the server dispatch runner (ActionDispatcher::registerAction), and the local Bridge::executeVia path — the last two throw ValidationError on a false result instead of running Model::execute.
  • ActionRecordingError — thrown when an action executed and its mutation committed, but serialising the result or appending the journal entry failed. Carries cause() (the underlying message) and result() (the committed action's result JSON, or "" when serialising it is what failed). Derives from std::runtime_error, so existing catch (const std::exception&) paths are unaffected.
  • Logging policy — ActionLogPolicy<A> and Loggable that 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), and ActionExecuteRegistry (client/schema-driven generic execute).
  • Macros — BRIDGE_REGISTER_MODEL, BRIDGE_REGISTER_ACTION, BRIDGE_REGISTER_VALIDATOR that specialise traits and register into singletons at static-init time.

Registration rules and invariants

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.

What the macros emit

BRIDGE_REGISTER_MODEL and BRIDGE_REGISTER_ACTION each emit two things at namespace scope:

  1. an explicit template specialisation — ModelTraits<M> or ActionTraits<A> — which has external visibility to the type system; and
  2. one or more file-scope initialiser objects ([[maybe_unused]] const bool in an anonymous namespace) whose initialisation runs registerModelOnce / 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).

Header placement is legal

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 initialisation only fires in linked translation units

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 runtime std::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. after main has 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.

Customisation traits

ModelTraits<Model>

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_MODEL

ActionTraits<Action>

Maps 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_ACTION

All 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.

payload_schema.hpp — the payload fingerprint

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.

Control bytes in action and result bodies

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 fromJson throws a ParseError on 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 two 0x00 bytes, 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.

Validation and logging policy

ActionValidator<Action>

Decides whether an in-progress action draft is ready to execute. Resolution order (highest priority first):

  1. Explicit specialisation — via BRIDGE_REGISTER_VALIDATOR(Action, fn).
  2. bool validate() const member on Action — auto-detected via the detail::HasValidate concept.
  3. Default — returns true (one-shot semantics: first set<> 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.

ValidationError

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.

Loggable

A strong enum avoiding bare bool arguments at registration sites:

enum class Loggable : std::uint8_t { No, Yes };

ActionLogPolicy<Action>

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.

fromJson is the codec boundary for an action payload

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 holders and factory

IModelHolder

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 without dynamic_cast. ModelHolder<M> answers isBackendChangeAware() from the BackendChangedNotifiable<M> concept and forwards onBackendChanged() to M::onBackendChanged() only when that concept holds; the base default is a no-op. See backend.md for how LocalBackend uses this.
  • into<Model>() down-casts to a concrete Model&; throws std::bad_cast on mismatch.
  • attachActionLog sets the durable log sink and the instance's stable identity (stamped onto every LogEntry), then calls the protected virtual onActionLogAttached(log, contextKey) (base default: no-op) before storing either. ModelHolder<Model> overrides this to forward to Model::attachActionLog(log, contextKey) when Model structurally satisfies ModelLevelActionLogAttachable (morph/core/model.hpp) — the same "detect the hook structurally, forward only if present" shape onBackendChanged()/BackendChangedMixin use below. This is what lets a model that keeps its own model-level IActionLog reference (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 no attachActionLog of 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 if primaryKey is empty. Otherwise forwards to the wrapped model's own attachIdentity(primaryKey) when it declares one matching ModelIdentityAttachable (morph/core/model.hpp) — the same "detect the hook structurally, forward only if present" shape attachActionLog/ModelLevelActionLogAttachable use above; a no-op otherwise. Called by ModelRegistryFactory::create(modelId, primary) immediately after construction — see that method below. Independent of attachActionLog/_contextKey: contextKey is the action log's entity-key field, primaryKey is the shared-instance directory key (see backend.md's BindRequest, which carries contextKey and primary as separate named fields, for why the two are kept as separate named fields rather than conflated); a caller wanting both calls both.
  • recordIfAttached is called automatically by ActionDispatcher's runner and Bridge::executeVia — model code never calls it directly. It fills entityKey, principal (from session::current()), and timestampMs on the entry before forwarding. It is also a no-op when isOutboxManaged() is true — see journal.md's transactional outbox section.
  • setOutboxManaged(true) marks this instance as managing its own outbox log write, so recordIfAttached stops auto-appending for it; hasActionLog() is unaffected. Defaults to false.

ModelHolder<Model>

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;
};

ModelFactory

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();
};

IBackendChangedSink and BackendChangedMixin

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.

Singleton registries

ActionDispatcher and ModelRegistryFactory are both declared in registry.hpp in namespace morph::model::detail. ActionExecuteRegistry lives elsewhere — see its section below.

ActionDispatcher

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. PairKeyHash and PairKeyEqual are transparent, so find takes a detail::PairKeyView — a pair of string_views — directly. Constructing the stored key instead costs two std::strings per lookup, and reaches the heap for any id past the 15-character SSO buffer. Measured with morph_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 registerAction and could not diverge in practice, but nothing said so. RemoteServer::handle also 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();
};
  • registerAction registers a runner that deserialises, reconciles any Quantity fields to their declared precision, rejects any Quantity field outside its unit's declared bounds (morph::forms::enforceQuantityBounds, throwing QuantityDecodeError; a no-op for actions with no Quantity members or whose units declare no bounds()), overwrites any declared computed fields from their inputs (morph::forms::recomputeAll, forms.md — a no-op for actions with no computedFields; runs after precision reconciliation and bounds enforcement and before the validator check, so the validator sees the authoritative computed value), enforces ActionValidator<Action>::ready(action) (throwing ValidationError on false, before Model::execute runs), then calls Model::execute(action) inside a try/catch (const std::exception&): on a throw it records outcome = Outcome::Failed (error = exc.what(), result empty) 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::execute is the only call inside that try: serialising the result and recording outcome = Outcome::Succeeded run after it, outside, because by then the mutation has committed and a throw from either is not an execution failure. Both surface as morph::model::ActionRecordingError instead — see journal.md, "A refused recording is not an execution failure". Mirrors Bridge::executeVia's localOp (bridge.md) for LocalBackend. See journal.md, "Outcome" for the full field/replay semantics. Every recorded entry — success or failure — is stamped with detail::actionPayloadSchema<Action>() in LogEntry::schema, and the same value is filed under (modelId, actionId) for schemaFor().
  • dispatch looks up the runner and invokes it; throws std::runtime_error for unknown pairs.
  • coalesce returns the ActionLogPolicy<Action>::coalesce value for the pair; unknown pairs default to false.
  • schemaFor returns the payload fingerprint the pair was registered with, or the empty string for an unregistered pair (or one whose ActionTraits is hand-written and supplies no payloadSchema()). This is the type-erased half of the journal's payload-evolution check: registerAction knows the concrete Action and can compute the fingerprint, while journal::replay() sees only the strings on a LogEntry and has to ask for it by id. The empty return is not an error — an entry naming an unregistered action fails at dispatch() with "unknown action" a moment later, which is the better diagnostic for that case.
  • schemasJson returns the {actionType: schema} document for every action registered under modelId, 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 what RemoteServer serves for the "schemas" envelope kind — see wire.md, "Serving action schemas".
  • requiredFieldsFor returns the wire names the pair's served schema lists in required, or nullptr. nullptr means nothing to check (the pair is unregistered, or its schema could not be generated), never nothing is required. Read by RemoteServer's opt-in PayloadCompleteness::RequireDeclaredFields gate, so the rule the server enforces is by construction the rule the schema published.

ActionDescription — what registerAction files for the wire

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>()'s UnsatisfiableFormError throw path would abort the process before main rather than surface as an err reply.
  • Cached in a function-local static (actionDescription<A>()), like forms::schemaJson<A>()'s own cache: RemoteServer may answer "schemas" on any pool thread, and the map ActionDispatcher fills 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 as forms::schemaJson always has; an action whose codec maps to different JSON is described by what its struct reflects, the same caveat payloadFingerprint carries.

ModelRegistryFactory

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();
};
  • create throws std::runtime_error for unknown model types. The optional @p primary parameter is the instance's stable primary key (empty for an anonymous instance); after building the holder, create calls holder->attachIdentity(primary) on it, which reaches the wrapped model's own attachIdentity when it declares one (see IModelHolder::attachIdentity above) — 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 lets RemoteServer's two construction call sites (acquireSharedInstance's directory-miss branch, and the plain register branch — both already have env.primary in 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 to registerModel<Model>(modelId, [] { return ModelFactory::create<Model>(); }) — and is what BRIDGE_REGISTER_MODEL always uses.
  • The two-argument overload is the per-instance dependency-injection seam for registry-constructed (Socket-mode) models — the equivalent, for RemoteServer's registry path, of Bridge::HandlerBinding::modelFactory for the client-side Local-mode path. factory runs once per create(modelId) call (i.e. once per incoming "register" request, and once per fresh shared-instance creation — see acquireSharedInstance, remote.hpp), and may capture and hand the model constructor arbitrary per-instance dependencies an ordinary default constructor cannot reach: an injectable clock (see docs/spec/util/datetime.md's now() 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). factory returns an owning pointer convertible to std::unique_ptr<IModelHolder> (e.g. std::make_unique<ModelHolder<Model>>(...)) — the caller controls construction end-to-end, including which ModelHolder<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 via IModelHolder::attachActionLog, or to rely on RemoteServer's LogProvider doing so afterward, exactly as the default path already allows. Two registrations for the same modelId still silently last-write-wins, as for the single-argument overload (see Failure modes) — registering a custom factory under an id that a BRIDGE_REGISTER_MODEL(Model, id) invocation already claimed overwrites that default-construction factory, and vice versa, whichever static-init/runtime call happens last.

Instance identity and per-instance authorization

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.

ActionExecuteRegistry

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();
};
  • registerAction is only declared in the class body; its definition is out-of-line in bridge.hpp (after BridgeHandler is fully defined) so the executor can safely cast the void* handler and call its methods. execute and instance() are defined inline in bridge.hpp.
  • execute throws std::runtime_error for unknown keys.

The key is a triple, not a pair — (modelId, actionId, typeid(Sharing))

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).

Static-init helpers

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.

Why all three are noexcept while allocating

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.

Registration macros

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.

BRIDGE_REGISTER_MODEL(M, NAME)

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_ONLY is defined: a [[maybe_unused]] const bool in an anonymous namespace (internal linkage, no explicit static) that calls detail::registerModelOnce<M>(NAME). See MORPH_CLIENT_ONLY.

BRIDGE_REGISTER_ACTION(M, A, NAME, ...)

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> with Result deduced from decltype(std::declval<M&>().execute(std::declval<A>())), a static constexpr std::string_view typeId() (no noexcept, unlike ModelTraits::typeId()), a static constexpr Loggable loggable, and four JSON codec functions (each throwing detail::ParseError on failure): toJson/ resultToJson use glz::write<detail::EscapingWriteOpts{}> (see "Control bytes in action and result bodies"); fromJson/resultFromJson use glz::read<glz::opts{.error_on_unknown_keys = false}> — the same forward-compatibility convention wire::decode uses (see wire.md, "Action-evolution policy") — so an older-compiled action struct silently ignores an additive field a newer peer sent.
  • Unless MORPH_CLIENT_ONLY is defined: a [[maybe_unused]] const bool in an anonymous namespace calling detail::registerActionOnce<M, A>(morph::model::ModelTraits<M>::typeId(), NAME) (the model-id argument is the model's registered typeId(), not a raw string). See MORPH_CLIENT_ONLY.
  • A [[maybe_unused]] const bool in an anonymous namespace calling detail::registerActionExecutorOnce<M, A>(morph::model::ModelTraits<M>::typeId(), NAME) — always emitted, MORPH_CLIENT_ONLY or 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.

Moving a registrar out of the header

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).

The link-time canary: closing BRIDGE_DECLARE_*'s own new hazard

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's extern template reference. 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.

BRIDGE_REGISTER_VALIDATOR(A, FN)

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); } };.

MORPH_CLIENT_ONLY — suppressing model-owning registrars

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-level ModelRegistryFactory, used by LocalBackend/RemoteServer to construct a live instance.
  • registerActionOnce<M, A> stores a runner in the process-level ActionDispatcher that calls Model::execute(...) directly on a live holder — the server-side dispatch path RemoteServer uses.

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.

BRIDGE_REGISTER_ACTION_FOR_CLIENT — a header seam for MORPH_CLIENT_ONLY

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.

API reference

Traits and policies

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.

Concepts (detail)

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().

Type-erased model infrastructure

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().

Singleton registries

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".

Macros

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.

Detail helpers

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.

Design decisions

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.

Thread safety

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). Every registerAction / registerModel mutation therefore happens-before any code that could observe the map concurrently.
  • After main() begins the maps are read-only. dispatch, create, and coalesce only ever call find on an already-populated map — concurrent reads of a const-in-practice unordered_map are data-race-free. This is what lets RemoteServer and 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.

The registration-phase latch

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, which NDEBUG compiles out, so the auto-close that feeds it is compiled out too: ActionDispatcher::dispatch is a per-request path and must not pay for a check nothing reads. A release build therefore answers registrationPhaseClosed() with false until the application closes the latch itself. closeRegistrationPhase() and registrationPhaseClosed() 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).

Failure modes

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.

Limitations

  • String type-ids are an unversioned, un-namespaced global protocol vocabulary. Every NAME passed to BRIDGE_REGISTER_MODEL / BRIDGE_REGISTER_ACTION lives 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 via BRIDGE_DECLARE_MODEL/BRIDGE_DECLARE_ACTION plus BRIDGE_REGISTER_MODEL_SOURCE/BRIDGE_REGISTER_ACTION_SOURCE gets 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 combined BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION carry 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() and defaultRegistry() (and ActionExecuteRegistry::instance()) are function-local statics 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. Contrast journal::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/PairKeyEqual are transparent, and find is given a PairKeyView), so dispatch, coalesce, schemaFor and requiredFieldsFor build no key at all. ModelRegistryFactory::create and ActionExecuteRegistry::execute still construct their keys to probe the map; ActionExecuteRegistry's key carries a type_index as well as the two ids, so it is not the same change.
  • The ActionDispatcher / ActionExecuteRegistry split can silently diverge. A single BRIDGE_REGISTER_ACTION populates both registries (one initialiser each). But they are independent maps consulted by different code paths — ActionDispatcher on the RemoteServer server path, ActionExecuteRegistry on the BridgeHandler schema-driven path. If only one registration fires (e.g. a hand-written ActionTraits specialisation 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. registerAction enumerates NoSharing and AllowShared explicitly, but BridgeHandler's Sharing template parameter is unconstrained — nothing rejects BridgeHandler<M, MyOwnTag> at compile time, and such a handler behaves as NoSharing everywhere except executeJson, which throws "unknown action" for an action that is registered. A concept constraining Sharing to the two tags would turn that runtime surprise into a compile error; none exists today.

Cross-references

  • bridge.md — defines BridgeHandler<Model>, Bridge, and the ActionExecuteRegistry class itself (declared in <morph/core/bridge.hpp>, not registry.hpp). Explains the hard #include <morph/core/bridge.hpp> requirement for any TU using BRIDGE_REGISTER_ACTION (registerActionExecutorOnce is only defined there), the parallel executor path this spec's ActionExecuteRegistry section summarises, and Bridge::executeVia's localOp, which enforces the same ValidationError gate as this spec's ActionDispatcher::registerAction for 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 into validate() alongside it; and computed/computeList/recomputeAll, both invoked by ActionDispatcher::registerAction's runner before Model::execute.
  • journal.md — IActionLog, LogEntry, SessionLog, checkpoint coalescing, and ScopedActionLog. Explains how the runner's recordIfAttached call and ActionLogPolicy<Action>::coalesce feed the durable log, and provides the scoped-install pattern the registries lack. Also LogEntry::idempotencyKey and journal::OutboxRelay, the transactional outbox this spec's setOutboxManaged/isOutboxManaged opt-out enables — see Transactional outbox (opt-in).
  • backend.md — backends store IModelHolders in a single map and drive backend-change notification via IModelHolder::isBackendChangeAware()/ onBackendChanged() (in turn backed by BackendChangedMixin/IBackendChangedSink); the model instances created by ModelRegistryFactory land here.
  • security.md — the session::current() principal stamped onto every logged entry by recordIfAttached, and the trust boundary of the string-keyed remote dispatch surface.
  • Error handling — the detail::ParseError / std::runtime_error taxonomy the registries raise (see Failure modes); glaze codec errors originate in the ActionTraits JSON functions.
  • Concurrency and lifetimes — the static-init-then-read-only discipline in Thread safety and the process-lifetime singleton ownership in Limitations.