You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
An informational comparison of morph's local/remote transparency and typed RPC layer against the systems that have solved (or claimed to solve) the same problem: Qt Remote Objects (same ecosystem, closest neighbour), gRPC and Cap'n Proto RPC (IDL-driven), Meteor DDP and tRPC (no-IDL, types from the implementation), and CORBA / ZeroC Ice (the historical location-transparency attempt whose failure modes the 1994 "A Note on Distributed Computing" critique named).
This is a survey, not a scorecard. Most of what separates morph from gRPC or Cap'n Proto is scope — morph is a typed action bridge for a C++ GUI over a request/response domain model, not a polyglot RPC transport, and its own README says so. The parts worth reading are §3 (does morph's headline transparency claim survive the CORBA critique, and where does it leak), §4 (schema evolution — morph's fields have names but no identities), and §8 (candidate gaps).
Six candidate gaps are named in §8. They are exploratory, not roadmap items; two of them overlap existing issues (#174, #116) and are framed as "the part that issue doesn't cover".
Triage note (post-filing): the sharpest gap (§4/§8 gap 2 — action-schema
identity and version skew) has since been reproduced empirically against master42deb96 and turns out to be a near-exact duplicate of an
already-filed issue, #207, which carries its own independent repro and a
corrected framing (routing is checked; a validate() diagnostic gate already
ships; the fix is mechanical enforcement of the published policy, not naive
strict decoding). Gap 2 is cross-referenced to #207 rather than re-filed. Gaps
3 and 6 were verified as present, small, and actionable and have been split
into their own issues (#224, #225). Gaps 1, 4, and 5 remain
genuinely open design questions or speculative asks and are kept here,
parked in place with explicit re-entry triggers — see the corrected Candidate gaps — triage disposition
below. Three factual corrections from the same triage pass are applied in
place in the document: §3.3 item 11 attributes the execute-ordering ticket
gate to the wrong function name; §5/§8 gap 6 describes messagesPerSecond as
if it were a morph-wide server limit when it is Qt-transport-only; and §4/§8
gap 2's own text undersells its worst case (a payload sharing zero keys with
the action decodes and executes as a real mutation, not just "a renamed field
decodes to a default"). See Why this issue is rescope rather than uniformly parked
for why the container needed restructuring rather than blanket deferral.
Full comparison document
Click to expand: rpc-and-distributed-objects-vs-morph-2026-08-23.md
RPC and distributed-object systems vs. morph — a survey, not a scorecard
Date: 2026-08-23
morph sources: README.md, docs/ARCHITECTURE.md, docs/spec/core/{backend,bridge,wire,completion,registry}.md, docs/spec/README.md, include/morph/core/{bridge,wire,remote,registry}.hpp, examples/LADDER.md, examples/lims/README.md, read at master 42deb96.
External sources: linked inline and collected in §9.
1. Scope note
morph's headline claim is a locality claim: "the same call site works whether
the model runs in-process or across a socket." Actions are plain C++ aggregates
registered with BRIDGE_REGISTER_ACTION; JSON serialisation is reflected by
Glaze with no hand-written codecs and no IDL; results come back as Completion<T> whose callbacks fire on a chosen executor. Backends are LocalBackend (in-process), RemoteServer behind a JSON wire protocol over Qt
WebSockets or a Qt-free raw-socket transport, and SimulatedRemoteBackend for
tests.
The comparators span three different projects, and only one of them is trying to
do morph's job:
Qt Remote Objects is the closest neighbour: same ecosystem, same
"distributed object, not a service" instinct, an actual .rep IDL, and a
live-replication model rather than request/response.
gRPC and Cap'n Proto RPC are general-purpose polyglot RPC
transports. Their headline features — four streaming shapes, HTTP/2 flow
control, promise pipelining, capability references, per-language codegen —
are answers to questions morph does not ask. Where they are comparable is
schema evolution and deadlines, and those comparisons are sharp.
Meteor DDP and tRPC are the school morph belongs to
philosophically: no IDL, the contract is the implementation. DDP is
additionally the closest functional analogue to what a morph app does (a UI
synchronised against a server-side model over a WebSocket).
CORBA / ZeroC Ice are the precedent. They made morph's exact claim,
much more strongly, and Waldo et al.'s 1994
critique is the standard answer to
why it did not hold. §3 engages with that rather than routing around it.
Nothing below argues that morph should acquire streaming, an IDL, or a
capability model. §7 lists the things that are domain mismatches so they are not
mistaken for gaps.
2. Side-by-side
Dimension
morph
Qt Remote Objects
gRPC
Cap'n Proto RPC
DDP
tRPC
CORBA / Ice
Contract source
The C++ action struct; Glaze reflects it. No IDL, no codegen step. BRIDGE_REGISTER_ACTION supplies string ids.
.rep DSL compiled by repc into source/replica/merged headers (or dynamic replicas via QObject introspection)
.proto IDL → per-language codegen
.capnp schema → codegen
None; EJSON on the wire
The server's TypeScript types, imported as typeof appRouter
Slice / OMG IDL → codegen
Build step
None (header-only)
repc
protoc
capnp compile
None
None ("no code generation, runtime bloat or build step")
IDL compiler
Call shapes
Unary request/response only
Property replication, signals (source→replica), slot invocation (replica→source), slots with return values via QRemoteObjectPendingCall
Unary, server-stream, client-stream, bidi
Unary + promise pipelining; interface refs as first-class results
Everything by value. Locally, Model::execute receives the caller's own action object; remotely, a JSON round-trip of the same struct
Replica is a live proxy object; POD payloads by value
By value
Interface references pass by reference as capabilities — "they both designate an object to call and confer permission to call it"
By value
By value
Object references by reference (the thing the 1994 critique is about)
Failure surface
.onError(std::exception_ptr), identical call site local and remote; the set of reachable errors is much wider remotely (§3.2)
Replica State incl. Suspect on connection loss and SignatureMismatch; QRemoteObjectPendingCall::error()
Status codes; DEADLINE_EXCEEDED; either side may cancel
Exceptions propagate through pipelined promises
result carries error/reason/message
Typed errors, TS-level
Ice::LocalException (transport/runtime) vs. Slice-declared user exceptions
Deadlines
Two independent, opt-in, both default off: server LimitPolicy::executeTimeout → TimeoutError; client Bridge::setExecuteDeadline → ClientTimeoutError. Neither is transmitted
waitForFinished(timeout), waitForSource(timeout)
Deadline on the wire (grpc-timeout); server can query remaining time
Per-call
None in the protocol
Transport-level
Invocation timeouts
Cancellation
None. "morph never interrupts a running action"
None for an in-flight slot call
Either side, propagating
Promise cancellation
None
AbortSignal
Limited
Backpressure
Admission control, not flow control: maxInFlightExecutes → err "server busy", messagesPerSecond token bucket that drops silently, maxMessageBytes, maxLiveModels
Qt socket buffering
HTTP/2 windows; blocking writes surface backpressure to the app
Flow-controlled streams
None specified
Transport-level
Transport-level
Schema evolution
Documented additive-only policy (wire.md); lenient decode by field name; build-wide kProtocolVersion handshake, opt-in
.rep signature check → SignatureMismatch replica state
Immutable field numbers, reserved, unknown fields preserved
Immutable @Nordinals; renames explicitly safe
None
Compile-time; both ends are one program
IDL versioning by interface id
Skew detection
None at the action level (§4)
Yes, at connect
Structural, by number
Structural, by ordinal
None
Prevented, not detected
By interface id
Ordering
Per model instance, FIFO, on both paths — the remote path has an explicit ticket gate to preserve it (§3.3). Nothing across instances
"guarantees ordered receipt of updates" while the connection holds
Per stream
Per connection
Per connection
Per connection
Per connection
Server→client push
No.subscribe<R> is in-process fan-out on one Bridge
Yes — property notifications and signals
Server streaming
Yes
Yes, the central feature
Subscriptions
Callback objects / AMI
Transparency posture
"the same call site works" — but the API is async and fallible in both modes (§3)
"QtRO hides the fact that the processing is really remote"
None claimed — RPC is explicit
None claimed
None claimed
"as if you were calling a function"
Explicit: "the client does not need to know where the implementation of an Ice object resides"
3. Is location transparency achieved, or leaky?
3.1 The critique, taken seriously
Waldo, Wyant, Wollrath and Kendall (1994)
argued that objects in a distributed system must be dealt with differently from
objects in one address space, along four axes — latency, memory access, partial failure, and concurrency — and that of these, partial failure is
the one that cannot be papered over: a local call fails totally and
deterministically, while a remote call can fail partially and leave the caller
unable to determine whether the work happened. Systems that unified local and
remote objects, they argued, failed to meet basic requirements of robustness
and reliability. The critique lands squarely on CORBA and DCOM, and remains the standard reason
location transparency is treated as an anti-pattern: a remote call carries much
higher latency, and the connection may fail outright.
(Correction, per triage: the two short phrases above were originally presented
as direct quotations from the 1994 paper. The paper's canonical PDF was not
retrievable by either the original pass or a retry during triage — it 403s. The
paper's four-axis structure and its emphasis on partial failure as the
irreducible difference are corroborated independently by the paper's indexed
abstract and by secondary summaries, and nothing in §3.2's argument depends on
the paper's exact wording — so the structural claim stands. But a survey this
careful about provenance elsewhere should not carry quotation marks it cannot
back with the source text, so the phrasing above is now paraphrased rather than
quoted. The Ice material quoted below this point was fetched and verified
directly, and remains quoted verbatim.)
It is worth noting that even Ice, which still states plainly that "the client
does not need to know where the implementation of an Ice object resides,"
had to work at this: its release notes record that "collocated invocations
have been reimplemented to provide location transparency semantics that are much
more consistent with those of regular remote invocations", and its manual
still warns that a synchronous twoway collocated call runs on the calling
thread — "a collocated invocation behaves like a local, synchronous procedure
call. This can cause problems if, for example, the calling thread acquires a
lock that an operation implementation tries to acquire as well: unless you use
recursive mutexes, this will cause deadlock." That is the shape of the problem:
the API is uniform, the semantics are not, and the divergence shows up as a
deadlock in the local case.
3.2 Why morph's narrower claim survives the critique
morph's claim is much smaller than CORBA's, and the smallness is load-bearing.
Three structural facts do most of the work:
The local path already obeys the remote path's rules. There is no
synchronous local call whose signature gets silently upgraded to a remote
one. BridgeHandler::execute returns Completion<T> in both modes; a
caller must write .then(...)/.onError(...) in both; the callback is
marshalled onto an executor in both; the model runs on a strand, not on the
caller's thread, in both. Waldo et al.'s recommendation is to make the
interface reflect the harder case rather than hide it — morph does exactly
that, at the cost of making the local case more ceremonious than it needs
to be. Completion<T> being deliberately a "leaf callback primitive" with
no co_await, no chaining, and no wait()/get() (completion.md,
Limitations) is part of this: there is no synchronous escape hatch that
would work locally and deadlock remotely.
There is no distributed object graph. Actions and results are plain
aggregates passed by value. There are no cross-process object references,
no distributed garbage collection, no object identity that must be
preserved across the wire, and no way for a result to hand back a live
handle to server-side state. This is precisely the feature set that made
the unified-object vision untenable, and morph does not have it. Contrast
Cap'n Proto, which does pass interface references by reference as
capabilities — a much more ambitious position that requires a whole
capability protocol (its four "levels") to hold up.
The leaks are largely written down.backend.md's Limitations opens
with "Local and remote are not fully interchangeable", and its "Failure
modes" and "Thread context" sections tabulate per-path differences. That is
the difference between a leaky abstraction and a dishonest one.
So the claim is defensible — with the following residue, which is real.
3.3 The observable differences
Every item below is a difference a caller can observe between LocalBackend and RemoteServer, with where it is (or is not) documented.
#
Difference
Documented?
1
Model construction.LocalBackend runs the caller's factory closure (may capture anything, need not be default-constructible). RemoteServer ignores the factory and constructs via ModelRegistryFactory, requiring default-constructible + BRIDGE_REGISTER_MODEL. A model that works locally can fail remote register with err "unknown model type: ..."
Yes — backend.md, Limitations, first bullet
2
Authorization does not exist locally.IAuthorizer (authorize, authenticate, authorizeInstance, authorizeRegister) is consulted only on the remote path. A model reading session::current()->principal sees an authorizer-verified identity remotely and whatever the process put there locally
Yes — README ("the local backend does not authorize at all"), security.md
3
Exception types do not cross the wire. Locally .onError receives the concrete exception the model threw (e.g. ValidationError); remotely the strand's catch turns it into err exc.what() and the client resolves with a generic std::runtime_error — "the concrete type does not cross the wire"
Yes — registry.md
4
Error text differs verbatim. Unknown model id: local "model not found: id=<n>", remote bare "model not found". And "There is no typed 'model not found' exception on either path"
Yes — backend.md, Failure modes
5
Wire-only validation.reconcileDeclaredPrecision and enforceQuantityBounds run in ActionDispatcher::registerAction's runner (the decode path) but not in Bridge::executeVia's localOp — "that path never decodes JSON, so there is no wire dp or wire value to check against." A Quantity payload accepted as-is locally can be retagged or rejected (QuantityDecodeError) remotely
Yes — registry.md, forms.md
6
Reference semantics of the payload.localOp calls model.execute(*sharedAction) on the caller's own object (bridge.hpp); remotely the same struct is a JSON round-trip. Anything in an action or result that is not faithfully reflected by Glaze — a pointer, a handle, object identity, an unreflectable member — works locally and diverges silently remotely
Partly — implied by the wire model, not stated as a locality rule
7
A much wider failure set remotely.DisconnectedError, TimeoutError, ClientTimeoutError, BackendChangedError, err "server busy", err "too many models", err "server shutting down", err "unauthorized", err "connection closed", protocol-version refusal, oversized-frame refusal, and a frame silently dropped by messagesPerSecond with no reply at all. LocalBackend can produce none of these. The call site is identical; the error handling that call site needs is not
Yes, individually — backend.md; not collected as a locality delta
8
Registration is a blocking round-trip remotely. Locally a map insert; remotely a nested QEventLoop (Qt) or a condition-variable park (morph::net) — which on a WASM main thread aborts the page outright, which is why registerModelAsync exists at all (opt-in, default off)
Yes — backend.md, "Asynchronous registration"
9
Instance subscriptions do not cross the wire.subscribe<R> fans out to handlers on the same Bridge. Two clients sharing one server-side instance do not see each other's results. Locally, two handlers on one shared instance do
Yes — README, ARCHITECTURE.md
10
Instance lifetime is reclaimed by connection remotely. A dropped socket runs closeConnection and reclaims everything that connection registered; locally the only lifetime is handler scope
Yes — backend.md, "Connection scopes"
11
Latency and thread context. Local: strand post from the calling thread. Remote: serialise on the caller's thread → socket → pool thread (authorize/authenticate/lookup) → ordering gate → strand → reply → deserialise on the transport thread. backend.md tabulates which of serializeAction/deserializeResult/localOp runs where, per transport — three different tables for three transports
Yes — backend.md, "Thread context"
One place where morph did the work rather than documenting the leak. Item 11
would ordinarily have destroyed per-instance ordering on the remote path: RemoteServer::handle posts to a multi-worker pool, so two execute envelopes
for the same model, sent back to back, could finish their pre-strand work in
either order and reach _strand.post(mid, …) reversed. remote.hpp closes this
with a per-model ticket gate — a ticket is taken synchronously in handleImpl (remote.hpp:304-321; an earlier draft of this document attributed
this to a function named dispatchDecoded, which does not exist in the tree —
that name survives only in two stale comments at remote.hpp:1406 and remote.hpp:1439 describing this same gate), on the transport's own calling
thread and therefore in true send order, and dispatchExecute waits for its
turn immediately before the strand post (remote.hpp:1278-1281), releasing it
on every early-return path via a rejectAndRelease lambda
(remote.hpp:1110-1114) so a rejected call cannot stall the queue behind it. tests/test_remote_execute_ordering.cpp reproduces the race, and triage
independently re-ran the same forcing function (two-thread pool, an authorizer
that sleeps only on the first call) and confirmed the later-sent call still
completes second even though its pre-strand work finishes 260ms earlier. The
result is that "per-instance FIFO" means the same thing on both paths, which is
one of the strongest transparency properties morph actually holds. Two caveats
worth naming: the guarantee is per transport calling thread, so it holds per
connection rather than globally; and the wait blocks a pool thread
(cv.wait at remote.hpp:1470), so it trades a worker against ordering.
What is missing is not a mechanism, it is a page. The differences above are
individually documented across four specs plus the README, but nothing collects
them. docs/spec/README.md points a reader at backend.md for "the
per-operation behaviour differences between local and remote", and backend.md
delivers roughly half of them (construction, failure modes, thread context) —
not authorization, not exception typing, not wire-only validation, not the
subscription split. Meanwhile the README's headline sentence ("without the call
site caring whether the model runs in this process or across a socket") is
stronger than the sum of the specs. Ice's docs handle this better: collocation
has its own page whose job is to enumerate exactly how a collocated call differs
from a remote one.
4. Schema evolution: names without identities
This is the sharpest comparison in the survey, because the IDL systems' entire
compatibility story rests on one idea morph does not have.
What the IDL systems do. protobuf: field numbers "cannot be changed once
your message type is in use because it identifies the field in the message wire
format"; deleted numbers must be reserved; adding is safe, removing is safe
with a reservation, and renaming is safe on the binary wire because the name
is not the identity. Cap'n Proto is the same idea stated as rules: "New fields,
enumerants, and methods may be added … as long as each new member's number is
larger than all previous members"; "Any symbolic name can be changed, as long as
the type ID / ordinal numbers stay the same"; "You cannot change a field, method,
or enumerant's number"; "You cannot change a field or method parameter's type or
default value." Qt Remote Objects has no numbers but does have a check: a
replica whose .rep does not match the source's enters the QRemoteObjectReplica::SignatureMismatch state — a peer built against a
different interface is detected at connect, not on the first mis-decoded
payload.
What the no-IDL systems do. tRPC does not solve evolution; it dissolves it
by making both ends the same program — the client imports AppRouter = typeof appRouter, so skew inside a monorepo is a compile error and
skew outside one is undetectable. DDP does nothing at all: EJSON is schemaless,
and a method's arguments and result are whatever the two sides agree they are.
What morph does. More than DDP, less than any of the rest:
A documented policy (wire.md, "Action-evolution policy"): additive-only
within a major version, new fields must be optional or safely-defaulting,
"Never renumber or rename protocol vocabulary", a one-release deprecation
window, and removals or retypes require a kProtocolVersion bump.
A transport-level handshake: kind == "hello" carries protocolVersion, the server answers with a ProtocolRange, setSupportedVersionRange(min, max) lets a server narrow it, and interpretHelloReply classifies a pre-negotiation peer as LegacyPeer
rather than failing. This is a genuinely good piece of design — it degrades
correctly against an older peer in both directions.
Lenient decoding by field name. Both the outer wire::decode and the BRIDGE_REGISTER_ACTION-generated fromJson/resultFromJson read with error_on_unknown_keys = false (registry.hpp, four call sites).
Put together, this gives the intended behaviour for additions and removals, and
the wrong behaviour for the two changes the policy forbids:
Change
protobuf / Cap'n Proto
QtRO
morph
Add a field
Safe by construction
Signature mismatch → detected
Safe (older peer ignores; newer sees the default)
Remove a field
Safe with reserved
Detected
Safe-ish (newer peer ignores the old key)
Rename a field
Safe (number is the identity)
Detected
Silently decodes to the member's default
Retype a field
Forbidden, caught structurally
Detected
Decode error, or a silent default, depending on the types
Peer built against a different action shape
Structural mismatch
SignatureMismatch at connect
Not detected at all
Three things follow.
morph's fields have names but no identities. protobuf and Cap'n Proto
survive a rename because the name is not what is matched; QtRO survives one
because the whole interface is fingerprinted. morph matches by name and
nothing else, so a rename is indistinguishable from "the sender omitted this
field", which the lenient reader is specifically built to tolerate. The
policy that forbids renames is therefore load-bearing and unenforced — it is
a convention the compiler cannot check.
kProtocolVersion does not cover this. It is a build-wide transport
version (currently 1), not a per-action schema version. Two builds that
agree on the envelope format but disagree about one action's field names
negotiate successfully and then mis-decode. And negotiateProtocolVersion()
is opt-in: "nothing calls it automatically."
The executable form of this — an old client built with MORPH_CLIENT_ONLY run
against a new server, where an additive field must work and a renamed field must
fail loudly — is named as required work in both examples/LADDER.md and examples/lims/README.md, and is still unwritten.
Confirmed and sharpened by triage. This claim has since been reproduced
against a real RemoteServer, real handle(), and real BRIDGE_REGISTER_ACTION-generated codecs — no mocks. The rename case behaves
exactly as described (250-cent transfer decodes as 0 cents, server replies ok, the model's state is mutated with the wrong value). The sharper finding
the original pass did not make: a payload sharing zero keys with the action
struct is also accepted — it decodes to a fully value-initialized action and
executes as a real mutation with an ok reply, which is not "schema evolution
went wrong" but "the server executes a real mutation from a payload that never
described this action at all." The leniency is also bidirectional: resultFromJson defaults the same way, so a client reading a renamed result
field silently gets a zero-valued field back. This is tracked as its own issue, #207, which carries the independent repro plus two important corrections
worth carrying forward here: routing (modelType/actionType) is checked —
an unrelated action id is rejected, so it is not true that the server executes
"an action it never received", only that it executes a real action from a
payload with none of its declared fields — and a bool validate() const gate
already ships on this exact path (ActionDispatcher::registerAction running ActionValidator<Action>::ready) and catches both the rename and the
zero-shared-keys case when an action opts in, though it cannot distinguish "the
field was absent" from "the field was a legitimate zero". #207's conclusion,
which stands: the fix is mechanical enforcement of the wire's own published
evolution policy (a per-action fingerprint at hello being the strongest
candidate), not a change to decode strictness — strict decoding was measured
and found to break the additive-field case the lenient policy exists to
support. See #207 for the full record; gap 2 below points there rather than
re-filing.
5. Streaming and backpressure
Streaming. gRPC has four RPC shapes (unary, server-streaming,
client-streaming, bidirectional). Cap'n Proto's answer to the same latency
problem is different and more interesting — promise pipelining, where "the
results of an RPC call are returned to the client instantly, before the server
even receives the initial request", collapsing a chain of dependent calls into
one round trip. morph is request/response only.
For result streaming this is a scope choice and a correct one: a form-driven
desktop/WASM client submitting bounded user actions has no use for a
client-streaming channel, and adding one would drag in flow control, half-close
semantics, and a second lifetime model for Completion<T> — which is
deliberately a single-result leaf primitive.
The part that is not purely scope is server→client push. morph's two
closest functional analogues both have it as their central feature: DDP's sub → added/changed/removed/ready flow is the reason DDP exists, and
QtRO propagates property changes and signals from source to replica
continuously. morph's subscribe<R> looks like the same thing and is not: it
fans out to handlers on one Bridge, in one process, "best-effort and
unbuffered: no replay, no cursor, no coalescing", with no server-initiated push.
Because morph does ship shared server-side instances — two clients meeting on
one AccountModel — it creates the exact scenario where the absence is felt,
and the README says so plainly: "two separate clients sharing an instance do not
see each other's results until they ask again."
Backpressure. morph has admission control, not flow control: maxInFlightExecutes → err "server busy", maxLiveModels → err "too many models", maxMessageBytes → an immediate err (addressed via peekCallId's bounded prefix scan, since the frame is never decoded), and a
per-connection messagesPerSecond token bucket. gRPC by contrast has real
flow control — HTTP/2 windows, with the framework "delay[ing] returning from
write calls when sending too fast, giving applications natural backpressure
signals" (and a documented deadlock risk if both ends write without reading).
Rejection instead of flow control is proportionate for a GUI with a bounded
number of in-flight user actions, and it is simpler to reason about. One detail
is worth flagging on its own, though: a frame that finds the token bucket empty
is dropped silently — not replied to, not queued. From the caller's side
that is an execute that never resolves, and the only recovery is Bridge::setExecuteDeadline, which is off by default. An err "rate limited"
reply would cost nothing protocol-wise and would turn a hang into an error.
Scope correction, per triage:messagesPerSecond is a QtWebSocketServerConfig field, not a LimitPolicy one — it exists only
under include/morph/qt/ and src/qt/qt_websocket_server.cpp. The Qt-free morph::net transport has no rate limiter at all. The framing above (and gap 6
below) originally read as if this were a morph-wide server limit; it is one
transport's. That narrows where the fix belongs (the Qt server, not RemoteServer) but does not weaken the case for it: the same source file
already replies to an oversized frame via peekCallId's bounded prefix scan
rather than dropping it, so the "reply instead of drop" pattern this gap asks
for is already proven to work in that exact file, ten lines above the rate
limiter. See #225.
6. Deadlines, cancellation, retries
Deadlines exist but do not propagate. morph has two, both opt-in and both
defaulting to off:
LimitPolicy::executeTimeout (server): replies err "timeout" → TimeoutError. The model keeps running on its strand; the discarded result
is dropped via a shared once-flag.
Bridge::setExecuteDeadline (client): resolves the Completion with ClientTimeoutError; the real reply and the timer race, first-result-wins.
completion.md draws the distinction better than most frameworks bother to: TimeoutError "confirms the action is in flight server-side, so a blind retry
risks a duplicate. ClientTimeoutError confirms nothing, so a retry must be
idempotent (or reconciled) either way." That is a genuinely honest treatment of
partial failure, and it is exactly the indeterminacy Waldo et al. said could not
be hidden — morph does not hide it, it types it.
What it is not is a deadline in gRPC's sense. gRPC transmits the deadline on
the wire, the server can query the remaining time, and exceeding it yields DEADLINE_EXCEEDED on both sides. In morph the client's deadline is never
sent, so a server has no way to know the caller has already given up and keeps
burning a strand on work nobody will read.
Cancellation does not exist, deliberately and consistently. completion.md: "No cancellation. There is no handle to cancel an outstanding
operation." backend.md: "morph never interrupts a running action" — true of executeTimeout, of beginShutdown/drainedWithin, and of closeGracefully, all of which bound the caller's wait and never the work.
This is the right call for the strand model: Model::execute is arbitrary
user C++ with no cancellation points, and gRPC can do better only because its
handlers are expected to poll a context. Naming it as a difference is fair;
naming it as a gap would not be.
Retries are the caller's, with one exception. A live execute interrupted by
a socket drop resolves with DisconnectedError; Bridge re-registers handlers
on reconnect but does not replay the call. The offline layer does retry — SyncWorker drains IOfflineQueue with retry and dead-lettering — and QueueItem::idempotencyKey exists there as a "caller-supplied dedup token,
stable across subsystems and restarts". gRPC by contrast has declarative retry
and hedging policies in service config.
The interesting asymmetry: morph knows enough to tell callers a retry must be
idempotent, but nothing in an action's declaration says whether it is. The
idempotency key lives at the queue layer and is minted by the caller, so the
framework itself can never decide that a DisconnectedErrored call is safe to
resend — every application re-derives the same reasoning by hand.
7. The no-IDL school, and where morph goes further
tRPC's pitch is morph's pitch in another language: "no build or compile steps,
meaning no code generation, runtime bloat or build step", with the client
inheriting the server's types via createTRPCClient<AppRouter>. It works
because both ends are one TypeScript program. morph's version is
Glaze reflection plus BRIDGE_REGISTER_ACTION's string ids, and it works
because both ends are usually one C++ program built twice — which is why MORPH_CLIENT_ONLY exists (a client build that never links Model::execute),
and why the binary-skew test in §4 matters: it is the case where the "one
program" assumption stops holding.
morph does have one thing neither tRPC nor DDP has: morph::forms::schemaJson<A>()
emits a JSON Schema at runtime from the same action struct that drives
dispatch — units, decimal steps, field order, bounds, required — so a client
that shares no C++ types can still render the form and submit a valid payload.
That is a reflected-implementation system that also exports a machine-readable
contract, which is closer in spirit to gRPC server reflection than to tRPC,
except derived from the implementation type rather than from an IDL. It is the
most distinctive thing in this comparison.
The catch is that it does not yet close the skew problem it is positioned to
close: the schema is a pure function of the compiled action type (#164), and it
is neither versioned nor fingerprinted, so two peers can serve and consume
different schemas for the same action name without anything noticing.
DDP is worth one more note because it is the closest functional analogue. Its
answer to "the user acted and the round trip has not returned yet" is latency
compensation — client-side method stubs simulate the result optimistically and
are reconciled when the server's updated arrives — plus session resumption on
reconnect. morph has the offline queue, onBackendChanged(), and ReconnectCoordinator's ordered reconnect→activate→bind→replay, but no
optimistic-apply-then-reconcile primitive; conflict resolution is explicitly
"not a framework concern" (ARCHITECTURE.md). That is a defensible boundary, but
it means the DDP-shaped app has to build the compensating half itself.
8. Why a Qt developer would pick one or the other
Since QtRO is the direct neighbour, the trade is worth stating plainly.
Qt Remote Objects gives you a Replica that is a live QObject proxy of a Source — properties replicate, signals propagate source→replica, slot calls
forward replica→source, and slots with return values come back as a QRemoteObjectPendingCall. There is a node/registry discovery layer, .rep-based codegen, replica State transitions including Suspect on
connection loss, and SignatureMismatch when the two sides were built against
different interfaces. The cost is that your domain object must be a QObject
with a Qt-shaped API, and that continuous replication is the model whether or
not you want it.
morph gives you a domain model that is plain single-threaded C++ — no QObject, no moc — with the framework owning concurrency (strand per instance),
result marshalling, and transport. On top of the dispatch layer sit things QtRO
has no equivalent for: a session/authorization hook on every remote call,
schema-driven forms with exact unit-tagged decimal values, an ordered replayable
journal, offline queue and reconnect sequencing, and shared keyed instances with
a server-side directory. Qt is optional (MORPH_BUILD_QT), there is a Qt-free
raw-socket transport, and it builds for single-threaded WASM.
Pick QtRO if you have an existing QObject API you want mirrored across a
process boundary with live property/signal semantics, or you want node
discovery. Pick morph if your domain model should not be a QObject, you
want request/response with typed results plus forms/journal/offline, or you need
a non-Qt or WASM build. Neither lacking the other's headline feature is a gap.
The one thing QtRO has that morph lacks and that is gap-shaped is the
signature check — see §4.
(Originally framed as six equally-exploratory "candidate gaps" in rough order
of impact. Triage found that framing wrong: the six split into three different
shapes — one already-filed duplicate, two verified-present-and-actionable
items now split into their own issues, and three genuinely open design
questions or speculative asks kept here, parked with explicit re-entry
triggers. Each gap's original analysis is preserved below the disposition.)
No server→client push, so two clients on one shared instance cannot
converge. Kept here as an open design question, not split.subscribe<R>
is in-process fan-out on one Bridge. morph's own shared-instance feature
creates the situation where this bites, and both closest analogues (QtRO
property replication, DDP subscriptions) have it. This is the largest
capability gap in the survey, and also the most expensive — a server-side
subscription registry, a push envelope kind, and a durability/coalescing
story subscribe<R> explicitly does not have today. This is the same design
question as sibling issue Survey: virtual-actor and actor-sharding systems vs. morph's instance model — candidate gaps #198's gap G2 (also unfiled, also "two clients
sharing an instance still cannot see each other's changes") and should
become one issue, not two, whenever that design work is actually taken up —
filing it now, from either survey alone, risks exactly the kind of
near-duplicate this session has been watching for elsewhere. Revisit
when: the server→client push design work is scoped, at which point file
one issue covering both this survey's gap 1 and Survey: virtual-actor and actor-sharding systems vs. morph's instance model — candidate gaps #198's G2.
No single document enumerating the local↔remote behavioural delta. Split
out as No single document enumerating the local/remote behavioural delta #224 (documentation, area: docs). The differences in §3.3 are
real and mostly documented, but scattered across backend.md, registry.md, security.md and the README, and backend.md covers only
about half of them. The README's headline claim is stronger than the sum of
the specs. See No single document enumerating the local/remote behavioural delta #224 for the full eleven-row table and the proposed docs/spec/core/locality.md.
No declared per-action idempotency. Kept here, parked. idempotencyKey exists only at the IOfflineQueue layer and is
caller-minted. Because the framework cannot know whether an action is safe
to resend, it can never auto-retry a DisconnectedError/ClientTimeoutError,
and every application re-derives completion.md's "a retry must be
idempotent (or reconciled)" reasoning by hand. An action-level trait
(alongside Loggable) would let the framework make that call once. This is
genuinely speculative in the Survey: userver framework comparison — candidate framework gaps #115/Consider: deadline/cancellation propagation across the executor abstraction #116-Consider: a caching framework (TTL/eviction/cache-aside) #119 sense: no current caller is
blocked on it, and building it now would be speculative API surface with no
consumer. Revisit only if: a second call site needs to decide
programmatically whether a failed/timed-out action is safe to auto-retry —
i.e., an application-level retry policy is being built and keeps
re-deriving the same idempotence judgment by hand that completion.md already documents as the caller's responsibility.
Why this issue is rescope rather than uniformly parked
Sibling issue #115 was resolved uniformly as parked because all four of its
candidate gaps were the same kind of thing: absent future-framework features
with no present defect, each independently checkable against its own trigger.
This survey's six gaps do not share one shape: gap 2 turned out to be a
same-mechanism duplicate of an already-filed, already-reproduced defect (#207);
gaps 3 and 6 are verified-present, small, actionable items with no decision
left to defer; and gaps 1, 4, and 5 are the genuinely speculative kind #115's
children model. Parking the whole container the way #115 was parked would have
also parked the duplicate-of-#207 and the two actionable items — neither has
any decision content left to defer, and #207 in particular is already assigned
a fix direction. Splitting the actionable items out, pointing the duplicate at
its existing issue, and parking only the genuinely speculative remainder is the
correct shape here; rescope reflects that the container needed
restructuring, not that everything in it needed more time.
Explicitly not gaps
Called out in §5 and §7 of the document as domain mismatches rather than missing
features: gRPC's four streaming call shapes and HTTP/2 flow control; Cap'n
Proto's promise pipelining and capability/object-reference model (morph
deliberately has no distributed object graph — see §3.2, where its absence is
what makes the transparency claim survivable); polyglot IDL codegen; QtRO's node
registry and live property replication; CORBA-style cross-process object
identity and distributed GC. Cancellation is also not a gap: "morph never
interrupts a running action" is a consistent, documented stance that follows
from Model::execute being arbitrary user C++ on a strand.
Verification status
Read directly in this repository (master 42deb96), and the basis for every
claim about morph: the specs and headers listed in §9. Three code-level claims
were checked against source rather than taken from a spec:
the per-model execute-ordering ticket gate (include/morph/core/remote.hpp, takeExecuteTicket/awaitExecuteTurn/releaseExecuteTicket);
kLenientRead{.error_on_unknown_keys = false} on the generated fromJson/resultFromJson paths (include/morph/core/registry.hpp, four
sites);
localOp invoking model.execute(*sharedAction) on the caller's own object
(include/morph/core/bridge.hpp).
Update: the live-execute rename claim has since been confirmed by
execution. The original pass did not build or run morph, and reasoned the
rename-decodes-to-default behaviour on the live execute path from kLenientRead plus Glaze's name-based matching alone, flagging it as needing
confirmation before anyone designs against it. Triage has since reproduced it
directly against a real RemoteServer/handle()/BRIDGE_REGISTER_ACTION
build (see the correction inline in §4), and independently again on #207 with
its own probe. The claim is confirmed, worse than originally stated (a
zero-shared-keys payload also executes as a real mutation), and bidirectional
(resultFromJson too). #174's own journal-replay repro remains separately
unreproduced and is #174's concern, not this issue's.
Taken from other projects' published documentation, not from running or
reading their source: everything in §2 and §4–§8 about Qt Remote Objects, gRPC,
Protocol Buffers, Cap'n Proto, DDP, tRPC, ZeroC Ice, and Erlang. Links in §9.
Weakest source. The Waldo et al. 1994 paper itself was not retrievable
during this pass (the canonical PDF returned 403). Its argument is characterised
from the ACM/Springer records, the author's own listing, and secondary
summaries, which agree on the four-axis structure and on partial failure being
the central difference. The short phrases attributed to it in §3.1 should be
re-checked against the paper before being quoted as authoritative anywhere else.
Quotations from Ice, gRPC, protobuf, Cap'n Proto and tRPC come from pages I
fetched directly.
Summary
An informational comparison of morph's local/remote transparency and typed RPC layer against the systems that have solved (or claimed to solve) the same problem: Qt Remote Objects (same ecosystem, closest neighbour), gRPC and Cap'n Proto RPC (IDL-driven), Meteor DDP and tRPC (no-IDL, types from the implementation), and CORBA / ZeroC Ice (the historical location-transparency attempt whose failure modes the 1994 "A Note on Distributed Computing" critique named).
This is a survey, not a scorecard. Most of what separates morph from gRPC or Cap'n Proto is scope — morph is a typed action bridge for a C++ GUI over a request/response domain model, not a polyglot RPC transport, and its own README says so. The parts worth reading are §3 (does morph's headline transparency claim survive the CORBA critique, and where does it leak), §4 (schema evolution — morph's fields have names but no identities), and §8 (candidate gaps).
Six candidate gaps are named in §8. They are exploratory, not roadmap items; two of them overlap existing issues (#174, #116) and are framed as "the part that issue doesn't cover".
Full comparison document
Click to expand: rpc-and-distributed-objects-vs-morph-2026-08-23.md
RPC and distributed-object systems vs. morph — a survey, not a scorecard
Date: 2026-08-23
morph sources:
README.md,docs/ARCHITECTURE.md,docs/spec/core/{backend,bridge,wire,completion,registry}.md,docs/spec/README.md,include/morph/core/{bridge,wire,remote,registry}.hpp,examples/LADDER.md,examples/lims/README.md, read at master42deb96.External sources: linked inline and collected in §9.
1. Scope note
morph's headline claim is a locality claim: "the same call site works whether
the model runs in-process or across a socket." Actions are plain C++ aggregates
registered with
BRIDGE_REGISTER_ACTION; JSON serialisation is reflected byGlaze with no hand-written codecs and no IDL; results come back as
Completion<T>whose callbacks fire on a chosen executor. Backends areLocalBackend(in-process),RemoteServerbehind a JSON wire protocol over QtWebSockets or a Qt-free raw-socket transport, and
SimulatedRemoteBackendfortests.
The comparators span three different projects, and only one of them is trying to
do morph's job:
"distributed object, not a service" instinct, an actual
.repIDL, and alive-replication model rather than request/response.
transports. Their headline features — four streaming shapes, HTTP/2 flow
control, promise pipelining, capability references, per-language codegen —
are answers to questions morph does not ask. Where they are comparable is
schema evolution and deadlines, and those comparisons are sharp.
philosophically: no IDL, the contract is the implementation. DDP is
additionally the closest functional analogue to what a morph app does (a UI
synchronised against a server-side model over a WebSocket).
much more strongly, and Waldo et al.'s 1994
critique is the standard answer to
why it did not hold. §3 engages with that rather than routing around it.
Nothing below argues that morph should acquire streaming, an IDL, or a
capability model. §7 lists the things that are domain mismatches so they are not
mistaken for gaps.
2. Side-by-side
BRIDGE_REGISTER_ACTIONsupplies string ids..repDSL compiled byrepcinto source/replica/merged headers (or dynamic replicas viaQObjectintrospection).protoIDL → per-language codegen.capnpschema → codegentypeof appRouterrepcprotoccapnp compileQRemoteObjectPendingCallmethod/resultRPC plussub→added/changed/removed/readypushModel::executereceives the caller's own action object; remotely, a JSON round-trip of the same struct.onError(std::exception_ptr), identical call site local and remote; the set of reachable errors is much wider remotely (§3.2)Stateincl.Suspecton connection loss andSignatureMismatch;QRemoteObjectPendingCall::error()DEADLINE_EXCEEDED; either side may cancelresultcarrieserror/reason/messageIce::LocalException(transport/runtime) vs. Slice-declared user exceptionsLimitPolicy::executeTimeout→TimeoutError; clientBridge::setExecuteDeadline→ClientTimeoutError. Neither is transmittedwaitForFinished(timeout),waitForSource(timeout)grpc-timeout); server can query remaining timemaxInFlightExecutes→err "server busy",messagesPerSecondtoken bucket that drops silently,maxMessageBytes,maxLiveModelswire.md); lenient decode by field name; build-widekProtocolVersionhandshake, opt-in.repsignature check →SignatureMismatchreplica statereserved, unknown fields preserved@Nordinals; renames explicitly safesubscribe<R>is in-process fan-out on oneBridge3. Is location transparency achieved, or leaky?
3.1 The critique, taken seriously
Waldo, Wyant, Wollrath and Kendall (1994)
argued that objects in a distributed system must be dealt with differently from
objects in one address space, along four axes — latency, memory access,
partial failure, and concurrency — and that of these, partial failure is
the one that cannot be papered over: a local call fails totally and
deterministically, while a remote call can fail partially and leave the caller
unable to determine whether the work happened. Systems that unified local and
remote objects, they argued, failed to meet basic requirements of robustness
and reliability. The critique lands squarely on CORBA and DCOM, and
remains the standard reason
location transparency is treated as an anti-pattern: a remote call carries much
higher latency, and the connection may fail outright.
(Correction, per triage: the two short phrases above were originally presented
as direct quotations from the 1994 paper. The paper's canonical PDF was not
retrievable by either the original pass or a retry during triage — it 403s. The
paper's four-axis structure and its emphasis on partial failure as the
irreducible difference are corroborated independently by the paper's indexed
abstract and by secondary summaries, and nothing in §3.2's argument depends on
the paper's exact wording — so the structural claim stands. But a survey this
careful about provenance elsewhere should not carry quotation marks it cannot
back with the source text, so the phrasing above is now paraphrased rather than
quoted. The Ice material quoted below this point was fetched and verified
directly, and remains quoted verbatim.)
It is worth noting that even Ice, which still states plainly that "the client
does not need to know where the implementation of an Ice object resides,"
had to work at this: its release notes record that "collocated invocations
have been reimplemented to provide location transparency semantics that are much
more consistent with those of regular remote invocations", and its manual
still warns that a synchronous twoway collocated call runs on the calling
thread — "a collocated invocation behaves like a local, synchronous procedure
call. This can cause problems if, for example, the calling thread acquires a
lock that an operation implementation tries to acquire as well: unless you use
recursive mutexes, this will cause deadlock." That is the shape of the problem:
the API is uniform, the semantics are not, and the divergence shows up as a
deadlock in the local case.
3.2 Why morph's narrower claim survives the critique
morph's claim is much smaller than CORBA's, and the smallness is load-bearing.
Three structural facts do most of the work:
The local path already obeys the remote path's rules. There is no
synchronous local call whose signature gets silently upgraded to a remote
one.
BridgeHandler::executereturnsCompletion<T>in both modes; acaller must write
.then(...)/.onError(...)in both; the callback ismarshalled onto an executor in both; the model runs on a strand, not on the
caller's thread, in both. Waldo et al.'s recommendation is to make the
interface reflect the harder case rather than hide it — morph does exactly
that, at the cost of making the local case more ceremonious than it needs
to be.
Completion<T>being deliberately a "leaf callback primitive" withno
co_await, no chaining, and nowait()/get()(completion.md,Limitations) is part of this: there is no synchronous escape hatch that
would work locally and deadlock remotely.
There is no distributed object graph. Actions and results are plain
aggregates passed by value. There are no cross-process object references,
no distributed garbage collection, no object identity that must be
preserved across the wire, and no way for a result to hand back a live
handle to server-side state. This is precisely the feature set that made
the unified-object vision untenable, and morph does not have it. Contrast
Cap'n Proto, which does pass interface references by reference as
capabilities — a much more ambitious position that requires a whole
capability protocol (its four "levels") to hold up.
The leaks are largely written down.
backend.md's Limitations openswith "Local and remote are not fully interchangeable", and its "Failure
modes" and "Thread context" sections tabulate per-path differences. That is
the difference between a leaky abstraction and a dishonest one.
So the claim is defensible — with the following residue, which is real.
3.3 The observable differences
Every item below is a difference a caller can observe between
LocalBackendandRemoteServer, with where it is (or is not) documented.LocalBackendruns the caller's factory closure (may capture anything, need not be default-constructible).RemoteServerignores the factory and constructs viaModelRegistryFactory, requiring default-constructible +BRIDGE_REGISTER_MODEL. A model that works locally can fail remoteregisterwitherr "unknown model type: ..."backend.md, Limitations, first bulletIAuthorizer(authorize,authenticate,authorizeInstance,authorizeRegister) is consulted only on the remote path. A model readingsession::current()->principalsees an authorizer-verified identity remotely and whatever the process put there locallysecurity.md.onErrorreceives the concrete exception the model threw (e.g.ValidationError); remotely the strand'scatchturns it intoerr exc.what()and the client resolves with a genericstd::runtime_error— "the concrete type does not cross the wire"registry.md"model not found: id=<n>", remote bare"model not found". And "There is no typed 'model not found' exception on either path"backend.md, Failure modesreconcileDeclaredPrecisionandenforceQuantityBoundsrun inActionDispatcher::registerAction's runner (the decode path) but not inBridge::executeVia'slocalOp— "that path never decodes JSON, so there is no wiredpor wire value to check against." AQuantitypayload accepted as-is locally can be retagged or rejected (QuantityDecodeError) remotelyregistry.md,forms.mdlocalOpcallsmodel.execute(*sharedAction)on the caller's own object (bridge.hpp); remotely the same struct is a JSON round-trip. Anything in an action or result that is not faithfully reflected by Glaze — a pointer, a handle, object identity, an unreflectable member — works locally and diverges silently remotelyDisconnectedError,TimeoutError,ClientTimeoutError,BackendChangedError,err "server busy",err "too many models",err "server shutting down",err "unauthorized",err "connection closed", protocol-version refusal, oversized-frame refusal, and a frame silently dropped bymessagesPerSecondwith no reply at all.LocalBackendcan produce none of these. The call site is identical; the error handling that call site needs is notbackend.md; not collected as a locality deltaQEventLoop(Qt) or a condition-variable park (morph::net) — which on a WASM main thread aborts the page outright, which is whyregisterModelAsyncexists at all (opt-in, default off)backend.md, "Asynchronous registration"subscribe<R>fans out to handlers on the sameBridge. Two clients sharing one server-side instance do not see each other's results. Locally, two handlers on one shared instance docloseConnectionand reclaims everything that connection registered; locally the only lifetime is handler scopebackend.md, "Connection scopes"backend.mdtabulates which ofserializeAction/deserializeResult/localOpruns where, per transport — three different tables for three transportsbackend.md, "Thread context"One place where morph did the work rather than documenting the leak. Item 11
would ordinarily have destroyed per-instance ordering on the remote path:
RemoteServer::handleposts to a multi-worker pool, so twoexecuteenvelopesfor the same model, sent back to back, could finish their pre-strand work in
either order and reach
_strand.post(mid, …)reversed.remote.hppcloses thiswith a per-model ticket gate — a ticket is taken synchronously in
handleImpl(remote.hpp:304-321; an earlier draft of this document attributedthis to a function named
dispatchDecoded, which does not exist in the tree —that name survives only in two stale comments at
remote.hpp:1406andremote.hpp:1439describing this same gate), on the transport's own callingthread and therefore in true send order, and
dispatchExecutewaits for itsturn immediately before the strand post (
remote.hpp:1278-1281), releasing iton every early-return path via a
rejectAndReleaselambda(
remote.hpp:1110-1114) so a rejected call cannot stall the queue behind it.tests/test_remote_execute_ordering.cppreproduces the race, and triageindependently re-ran the same forcing function (two-thread pool, an authorizer
that sleeps only on the first call) and confirmed the later-sent call still
completes second even though its pre-strand work finishes 260ms earlier. The
result is that "per-instance FIFO" means the same thing on both paths, which is
one of the strongest transparency properties morph actually holds. Two caveats
worth naming: the guarantee is per transport calling thread, so it holds per
connection rather than globally; and the wait blocks a pool thread
(
cv.waitatremote.hpp:1470), so it trades a worker against ordering.What is missing is not a mechanism, it is a page. The differences above are
individually documented across four specs plus the README, but nothing collects
them.
docs/spec/README.mdpoints a reader atbackend.mdfor "theper-operation behaviour differences between local and remote", and
backend.mddelivers roughly half of them (construction, failure modes, thread context) —
not authorization, not exception typing, not wire-only validation, not the
subscription split. Meanwhile the README's headline sentence ("without the call
site caring whether the model runs in this process or across a socket") is
stronger than the sum of the specs. Ice's docs handle this better: collocation
has its own page whose job is to enumerate exactly how a collocated call differs
from a remote one.
4. Schema evolution: names without identities
This is the sharpest comparison in the survey, because the IDL systems' entire
compatibility story rests on one idea morph does not have.
What the IDL systems do. protobuf: field numbers "cannot be changed once
your message type is in use because it identifies the field in the message wire
format"; deleted numbers must be
reserved; adding is safe, removing is safewith a reservation, and renaming is safe on the binary wire because the name
is not the identity. Cap'n Proto is the same idea stated as rules: "New fields,
enumerants, and methods may be added … as long as each new member's number is
larger than all previous members"; "Any symbolic name can be changed, as long as
the type ID / ordinal numbers stay the same"; "You cannot change a field, method,
or enumerant's number"; "You cannot change a field or method parameter's type or
default value." Qt Remote Objects has no numbers but does have a check: a
replica whose
.repdoes not match the source's enters theQRemoteObjectReplica::SignatureMismatchstate — a peer built against adifferent interface is detected at connect, not on the first mis-decoded
payload.
What the no-IDL systems do. tRPC does not solve evolution; it dissolves it
by making both ends the same program — the client imports
AppRouter = typeof appRouter, so skew inside a monorepo is a compile error andskew outside one is undetectable. DDP does nothing at all: EJSON is schemaless,
and a method's arguments and result are whatever the two sides agree they are.
What morph does. More than DDP, less than any of the rest:
wire.md, "Action-evolution policy"): additive-onlywithin a major version, new fields must be optional or safely-defaulting,
"Never renumber or rename protocol vocabulary", a one-release deprecation
window, and removals or retypes require a
kProtocolVersionbump.kind == "hello"carriesprotocolVersion, the server answers with aProtocolRange,setSupportedVersionRange(min, max)lets a server narrow it, andinterpretHelloReplyclassifies a pre-negotiation peer asLegacyPeerrather than failing. This is a genuinely good piece of design — it degrades
correctly against an older peer in both directions.
wire::decodeand theBRIDGE_REGISTER_ACTION-generatedfromJson/resultFromJsonread witherror_on_unknown_keys = false(registry.hpp, four call sites).Put together, this gives the intended behaviour for additions and removals, and
the wrong behaviour for the two changes the policy forbids:
reservedSignatureMismatchat connectThree things follow.
morph's fields have names but no identities. protobuf and Cap'n Proto
survive a rename because the name is not what is matched; QtRO survives one
because the whole interface is fingerprinted. morph matches by name and
nothing else, so a rename is indistinguishable from "the sender omitted this
field", which the lenient reader is specifically built to tolerate. The
policy that forbids renames is therefore load-bearing and unenforced — it is
a convention the compiler cannot check.
kProtocolVersiondoes not cover this. It is a build-wide transportversion (currently
1), not a per-action schema version. Two builds thatagree on the envelope format but disagree about one action's field names
negotiate successfully and then mis-decode. And
negotiateProtocolVersion()is opt-in: "nothing calls it automatically."
Journal payload evolution: a renamed field decodes to its default, silently, so "reconstructible from the journal alone" is not true across a schema change #174 is the same mechanism, one layer up. That issue is about journal
replay decoding stored payloads with current action structs. The identical
kLenientReadpath is on liveexecutetraffic, so the exposure is notlimited to the audit trail — an old client talking to a new server hits it in
real time. Journal payload evolution: a renamed field decodes to its default, silently, so "reconstructible from the journal alone" is not true across a schema change #174's option 2 ("reject rather than degrade") and option 3
("freeze action payloads by contract") both generalise; a third option the
comparators suggest is a per-action fingerprint — a hash of the reflected
field names/types, exchanged at
helloor on first use of an action — whichis QtRO's
SignatureMismatchin morph's vocabulary and would turn a silentdefault into a loud refusal without any per-field numbering.
The executable form of this — an old client built with
MORPH_CLIENT_ONLYrunagainst a new server, where an additive field must work and a renamed field must
fail loudly — is named as required work in both
examples/LADDER.mdandexamples/lims/README.md, and is still unwritten.Confirmed and sharpened by triage. This claim has since been reproduced
against a real
RemoteServer, realhandle(), and realBRIDGE_REGISTER_ACTION-generated codecs — no mocks. The rename case behavesexactly as described (250-cent transfer decodes as 0 cents, server replies
ok, the model's state is mutated with the wrong value). The sharper findingthe original pass did not make: a payload sharing zero keys with the action
struct is also accepted — it decodes to a fully value-initialized action and
executes as a real mutation with an
okreply, which is not "schema evolutionwent wrong" but "the server executes a real mutation from a payload that never
described this action at all." The leniency is also bidirectional:
resultFromJsondefaults the same way, so a client reading a renamed resultfield silently gets a zero-valued field back. This is tracked as its own issue,
#207, which carries the independent repro plus two important corrections
worth carrying forward here: routing (
modelType/actionType) is checked —an unrelated action id is rejected, so it is not true that the server executes
"an action it never received", only that it executes a real action from a
payload with none of its declared fields — and a
bool validate() constgatealready ships on this exact path (
ActionDispatcher::registerActionrunningActionValidator<Action>::ready) and catches both the rename and thezero-shared-keys case when an action opts in, though it cannot distinguish "the
field was absent" from "the field was a legitimate zero". #207's conclusion,
which stands: the fix is mechanical enforcement of the wire's own published
evolution policy (a per-action fingerprint at
hellobeing the strongestcandidate), not a change to decode strictness — strict decoding was measured
and found to break the additive-field case the lenient policy exists to
support. See #207 for the full record; gap 2 below points there rather than
re-filing.
5. Streaming and backpressure
Streaming. gRPC has four RPC shapes (unary, server-streaming,
client-streaming, bidirectional). Cap'n Proto's answer to the same latency
problem is different and more interesting — promise pipelining, where "the
results of an RPC call are returned to the client instantly, before the server
even receives the initial request", collapsing a chain of dependent calls into
one round trip. morph is request/response only.
For result streaming this is a scope choice and a correct one: a form-driven
desktop/WASM client submitting bounded user actions has no use for a
client-streaming channel, and adding one would drag in flow control, half-close
semantics, and a second lifetime model for
Completion<T>— which isdeliberately a single-result leaf primitive.
The part that is not purely scope is server→client push. morph's two
closest functional analogues both have it as their central feature: DDP's
sub→added/changed/removed/readyflow is the reason DDP exists, andQtRO propagates property changes and signals from source to replica
continuously. morph's
subscribe<R>looks like the same thing and is not: itfans out to handlers on one
Bridge, in one process, "best-effort andunbuffered: no replay, no cursor, no coalescing", with no server-initiated push.
Because morph does ship shared server-side instances — two clients meeting on
one
AccountModel— it creates the exact scenario where the absence is felt,and the README says so plainly: "two separate clients sharing an instance do not
see each other's results until they ask again."
Backpressure. morph has admission control, not flow control:
maxInFlightExecutes→err "server busy",maxLiveModels→err "too many models",maxMessageBytes→ an immediateerr(addressed viapeekCallId's bounded prefix scan, since the frame is never decoded), and aper-connection
messagesPerSecondtoken bucket. gRPC by contrast has realflow control — HTTP/2 windows, with the framework "delay[ing] returning from
write calls when sending too fast, giving applications natural backpressure
signals" (and a documented deadlock risk if both ends write without reading).
Rejection instead of flow control is proportionate for a GUI with a bounded
number of in-flight user actions, and it is simpler to reason about. One detail
is worth flagging on its own, though: a frame that finds the token bucket empty
is dropped silently — not replied to, not queued. From the caller's side
that is an execute that never resolves, and the only recovery is
Bridge::setExecuteDeadline, which is off by default. Anerr "rate limited"reply would cost nothing protocol-wise and would turn a hang into an error.
Scope correction, per triage:
messagesPerSecondis aQtWebSocketServerConfigfield, not aLimitPolicyone — it exists onlyunder
include/morph/qt/andsrc/qt/qt_websocket_server.cpp. The Qt-freemorph::nettransport has no rate limiter at all. The framing above (and gap 6below) originally read as if this were a morph-wide server limit; it is one
transport's. That narrows where the fix belongs (the Qt server, not
RemoteServer) but does not weaken the case for it: the same source filealready replies to an oversized frame via
peekCallId's bounded prefix scanrather than dropping it, so the "reply instead of drop" pattern this gap asks
for is already proven to work in that exact file, ten lines above the rate
limiter. See #225.
6. Deadlines, cancellation, retries
Deadlines exist but do not propagate. morph has two, both opt-in and both
defaulting to off:
LimitPolicy::executeTimeout(server): replieserr "timeout"→TimeoutError. The model keeps running on its strand; the discarded resultis dropped via a shared once-flag.
Bridge::setExecuteDeadline(client): resolves theCompletionwithClientTimeoutError; the real reply and the timer race, first-result-wins.completion.mddraws the distinction better than most frameworks bother to:TimeoutError"confirms the action is in flight server-side, so a blind retryrisks a duplicate.
ClientTimeoutErrorconfirms nothing, so a retry must beidempotent (or reconciled) either way." That is a genuinely honest treatment of
partial failure, and it is exactly the indeterminacy Waldo et al. said could not
be hidden — morph does not hide it, it types it.
What it is not is a deadline in gRPC's sense. gRPC transmits the deadline on
the wire, the server can query the remaining time, and exceeding it yields
DEADLINE_EXCEEDEDon both sides. In morph the client's deadline is neversent, so a server has no way to know the caller has already given up and keeps
burning a strand on work nobody will read.
Cancellation does not exist, deliberately and consistently.
completion.md: "No cancellation. There is no handle to cancel an outstandingoperation."
backend.md: "morph never interrupts a running action" — true ofexecuteTimeout, ofbeginShutdown/drainedWithin, and ofcloseGracefully, all of which bound the caller's wait and never the work.This is the right call for the strand model:
Model::executeis arbitraryuser C++ with no cancellation points, and gRPC can do better only because its
handlers are expected to poll a context. Naming it as a difference is fair;
naming it as a gap would not be.
Retries are the caller's, with one exception. A live execute interrupted by
a socket drop resolves with
DisconnectedError;Bridgere-registers handlerson reconnect but does not replay the call. The offline layer does retry —
SyncWorkerdrainsIOfflineQueuewith retry and dead-lettering — andQueueItem::idempotencyKeyexists there as a "caller-supplied dedup token,stable across subsystems and restarts". gRPC by contrast has declarative retry
and hedging policies in service config.
The interesting asymmetry: morph knows enough to tell callers a retry must be
idempotent, but nothing in an action's declaration says whether it is. The
idempotency key lives at the queue layer and is minted by the caller, so the
framework itself can never decide that a
DisconnectedErrored call is safe toresend — every application re-derives the same reasoning by hand.
7. The no-IDL school, and where morph goes further
tRPC's pitch is morph's pitch in another language: "no build or compile steps,
meaning no code generation, runtime bloat or build step", with the client
inheriting the server's types via
createTRPCClient<AppRouter>. It worksbecause both ends are one TypeScript program. morph's version is
Glaze reflection plus
BRIDGE_REGISTER_ACTION's string ids, and it worksbecause both ends are usually one C++ program built twice — which is why
MORPH_CLIENT_ONLYexists (a client build that never linksModel::execute),and why the binary-skew test in §4 matters: it is the case where the "one
program" assumption stops holding.
morph does have one thing neither tRPC nor DDP has:
morph::forms::schemaJson<A>()emits a JSON Schema at runtime from the same action struct that drives
dispatch — units, decimal steps, field order, bounds,
required— so a clientthat shares no C++ types can still render the form and submit a valid payload.
That is a reflected-implementation system that also exports a machine-readable
contract, which is closer in spirit to gRPC server reflection than to tRPC,
except derived from the implementation type rather than from an IDL. It is the
most distinctive thing in this comparison.
The catch is that it does not yet close the skew problem it is positioned to
close: the schema is a pure function of the compiled action type (#164), and it
is neither versioned nor fingerprinted, so two peers can serve and consume
different schemas for the same action name without anything noticing.
DDP is worth one more note because it is the closest functional analogue. Its
answer to "the user acted and the round trip has not returned yet" is latency
compensation — client-side method stubs simulate the result optimistically and
are reconciled when the server's
updatedarrives — plus session resumption onreconnect. morph has the offline queue,
onBackendChanged(), andReconnectCoordinator's ordered reconnect→activate→bind→replay, but nooptimistic-apply-then-reconcile primitive; conflict resolution is explicitly
"not a framework concern" (ARCHITECTURE.md). That is a defensible boundary, but
it means the DDP-shaped app has to build the compensating half itself.
8. Why a Qt developer would pick one or the other
Since QtRO is the direct neighbour, the trade is worth stating plainly.
Qt Remote Objects gives you a
Replicathat is a liveQObjectproxy of aSource— properties replicate, signals propagate source→replica, slot callsforward replica→source, and slots with return values come back as a
QRemoteObjectPendingCall. There is a node/registry discovery layer,.rep-based codegen, replicaStatetransitions includingSuspectonconnection loss, and
SignatureMismatchwhen the two sides were built againstdifferent interfaces. The cost is that your domain object must be a
QObjectwith a Qt-shaped API, and that continuous replication is the model whether or
not you want it.
morph gives you a domain model that is plain single-threaded C++ — no
QObject, no moc — with the framework owning concurrency (strand per instance),result marshalling, and transport. On top of the dispatch layer sit things QtRO
has no equivalent for: a session/authorization hook on every remote call,
schema-driven forms with exact unit-tagged decimal values, an ordered replayable
journal, offline queue and reconnect sequencing, and shared keyed instances with
a server-side directory. Qt is optional (
MORPH_BUILD_QT), there is a Qt-freeraw-socket transport, and it builds for single-threaded WASM.
Pick QtRO if you have an existing
QObjectAPI you want mirrored across aprocess boundary with live property/signal semantics, or you want node
discovery. Pick morph if your domain model should not be a
QObject, youwant request/response with typed results plus forms/journal/offline, or you need
a non-Qt or WASM build. Neither lacking the other's headline feature is a gap.
The one thing QtRO has that morph lacks and that is gap-shaped is the
signature check — see §4.
9. Sources
morph, read at master
42deb96:README.md,docs/ARCHITECTURE.md,docs/spec/README.md,docs/spec/core/backend.md,docs/spec/core/bridge.md,docs/spec/core/wire.md,docs/spec/core/completion.md,docs/spec/core/registry.md,docs/spec/offline/offline.md,include/morph/core/bridge.hpp,include/morph/core/wire.hpp,include/morph/core/remote.hpp,include/morph/core/registry.hpp,examples/LADDER.md,examples/lims/README.md, and issues #115 / #174.External:
remote object interaction,
QRemoteObjectReplica,QRemoteObjectPendingCall,repc /
.repDSLflow control
schema language, "Evolving Your Protocol"
new features in Ice 3.6
Candidate gaps — triage disposition
(Originally framed as six equally-exploratory "candidate gaps" in rough order
of impact. Triage found that framing wrong: the six split into three different
shapes — one already-filed duplicate, two verified-present-and-actionable
items now split into their own issues, and three genuinely open design
questions or speculative asks kept here, parked with explicit re-entry
triggers. Each gap's original analysis is preserved below the disposition.)
No server→client push, so two clients on one shared instance cannot
converge. Kept here as an open design question, not split.
subscribe<R>is in-process fan-out on one
Bridge. morph's own shared-instance featurecreates the situation where this bites, and both closest analogues (QtRO
property replication, DDP subscriptions) have it. This is the largest
capability gap in the survey, and also the most expensive — a server-side
subscription registry, a push envelope kind, and a durability/coalescing
story
subscribe<R>explicitly does not have today. This is the same designquestion as sibling issue Survey: virtual-actor and actor-sharding systems vs. morph's instance model — candidate gaps #198's gap G2 (also unfiled, also "two clients
sharing an instance still cannot see each other's changes") and should
become one issue, not two, whenever that design work is actually taken up —
filing it now, from either survey alone, risks exactly the kind of
near-duplicate this session has been watching for elsewhere. Revisit
when: the server→client push design work is scoped, at which point file
one issue covering both this survey's gap 1 and Survey: virtual-actor and actor-sharding systems vs. morph's instance model — candidate gaps #198's G2.
No action-schema identity, so version skew is undetectable. Not split —
duplicate of Lenient wire decode means a client/server field-name skew is accepted silently, and nothing enforces the published evolution policy #207. Fields are matched by name, reads are lenient
(
error_on_unknown_keys = false), andkProtocolVersionis a build-widetransport version, not a per-action schema version. This has been
independently reproduced (see the correction inline in §4 above) against
live
executetraffic, with a sharper worst case than originally stated: apayload sharing zero keys with the action still executes as a real mutation
with an
okreply. This is the same gap already tracked, with its ownindependent repro and corrected framing, as Lenient wire decode means a client/server field-name skew is accepted silently, and nothing enforces the published evolution policy #207 ("Lenient wire decode
means a client/server field-name skew is accepted silently, and nothing
enforces the published evolution policy") — filing a second issue for it
here would duplicate Lenient wire decode means a client/server field-name skew is accepted silently, and nothing enforces the published evolution policy #207. All further work on this gap belongs on Lenient wire decode means a client/server field-name skew is accepted silently, and nothing enforces the published evolution policy #207,
which also covers this survey's Journal payload evolution: a renamed field decodes to its default, silently, so "reconstructible from the journal alone" is not true across a schema change #174 cross-reference (same
kLenientReadmechanism, at the wire site rather than the journal site).
No single document enumerating the local↔remote behavioural delta. Split
out as No single document enumerating the local/remote behavioural delta #224 (
documentation,area: docs). The differences in §3.3 arereal and mostly documented, but scattered across
backend.md,registry.md,security.mdand the README, andbackend.mdcovers onlyabout half of them. The README's headline claim is stronger than the sum of
the specs. See No single document enumerating the local/remote behavioural delta #224 for the full eleven-row table and the proposed
docs/spec/core/locality.md.The client's deadline is not transmitted. Kept here, parked.
Bridge::setExecuteDeadlinebounds the caller's wait but the server neverlearns of it, so it keeps burning a strand on work nobody will read. Adding
the remaining budget as an envelope field the server compares against its
own
executeTimeoutwould be a small, additive change and is the one pieceof gRPC's deadline story that is in scope. This overlaps Consider: deadline/cancellation propagation across the executor abstraction #116 (
triage: parked, "deadline/cancellation propagation across the executorabstraction") — Consider: deadline/cancellation propagation across the executor abstraction #116's own deep-analysis comment already concluded the
trigger condition (actions fanning out into multiple downstream calls) is
unmet across all 17 implemented example models, and recommended deferring
until the first model or bridge-level orchestration makes a second,
separately-dispatched call whose wait isn't bounded by the first hop's own
deadline, with Kanban/ThreadSanitizer still races after #127's fix: Qt-internal QCallableObject reuse, kanban-tsan job's own premise is wrong #128 (open TSan findings in the completion/teardown path)
resolved first regardless, since a wire deadline would be a third concurrent
settler racing the same teardown surface. This survey's gap is the wire
half of the same question (transmitting the deadline), which Consider: deadline/cancellation propagation across the executor abstraction #116 doesn't
itself cover, but the two share a trigger and Consider: deadline/cancellation propagation across the executor abstraction #116's "resolve Kanban/ThreadSanitizer still races after #127's fix: Qt-internal QCallableObject reuse, kanban-tsan job's own premise is wrong #128 first"
condition applies here too. Revisit when: Consider: deadline/cancellation propagation across the executor abstraction #116's trigger fires (a
second, separately-dispatched hop appears) — at that point the wire-deadline
half from this survey and Consider: deadline/cancellation propagation across the executor abstraction #116's executor-propagation half should likely be
designed together.
No declared per-action idempotency. Kept here, parked.
idempotencyKeyexists only at theIOfflineQueuelayer and iscaller-minted. Because the framework cannot know whether an action is safe
to resend, it can never auto-retry a
DisconnectedError/ClientTimeoutError,and every application re-derives
completion.md's "a retry must beidempotent (or reconciled)" reasoning by hand. An action-level trait
(alongside
Loggable) would let the framework make that call once. This isgenuinely speculative in the Survey: userver framework comparison — candidate framework gaps #115/Consider: deadline/cancellation propagation across the executor abstraction #116-Consider: a caching framework (TTL/eviction/cache-aside) #119 sense: no current caller is
blocked on it, and building it now would be speculative API surface with no
consumer. Revisit only if: a second call site needs to decide
programmatically whether a failed/timed-out action is safe to auto-retry —
i.e., an application-level retry policy is being built and keeps
re-deriving the same idempotence judgment by hand that
completion.mdalready documents as the caller's responsibility.A rate-limited frame is dropped with no reply. Split out as A rate-limited frame on the Qt WebSocket transport is dropped with no reply, hanging the caller's Completion #225
(
enhancement,area: qt).messagesPerSecond's token bucket dropssilently — "not replied to, not queued" — so the caller's
Completionsimply never resolves unless
setExecuteDeadline(off by default) isarmed. Scope-corrected per the note in §5 above: this is a
QtWebSocketServerConfig/Qt-transport limitation, not aRemoteServer-wideone. See A rate-limited frame on the Qt WebSocket transport is dropped with no reply, hanging the caller's Completion #225 for the fix, which reuses the existing
maxMessageBytesreplypattern in the same source file.
Why this issue is
rescoperather than uniformlyparkedSibling issue #115 was resolved uniformly as
parkedbecause all four of itscandidate gaps were the same kind of thing: absent future-framework features
with no present defect, each independently checkable against its own trigger.
This survey's six gaps do not share one shape: gap 2 turned out to be a
same-mechanism duplicate of an already-filed, already-reproduced defect (#207);
gaps 3 and 6 are verified-present, small, actionable items with no decision
left to defer; and gaps 1, 4, and 5 are the genuinely speculative kind #115's
children model. Parking the whole container the way #115 was parked would have
also parked the duplicate-of-#207 and the two actionable items — neither has
any decision content left to defer, and #207 in particular is already assigned
a fix direction. Splitting the actionable items out, pointing the duplicate at
its existing issue, and parking only the genuinely speculative remainder is the
correct shape here;
rescopereflects that the container neededrestructuring, not that everything in it needed more time.
Explicitly not gaps
Called out in §5 and §7 of the document as domain mismatches rather than missing
features: gRPC's four streaming call shapes and HTTP/2 flow control; Cap'n
Proto's promise pipelining and capability/object-reference model (morph
deliberately has no distributed object graph — see §3.2, where its absence is
what makes the transparency claim survivable); polyglot IDL codegen; QtRO's node
registry and live property replication; CORBA-style cross-process object
identity and distributed GC. Cancellation is also not a gap: "morph never
interrupts a running action" is a consistent, documented stance that follows
from
Model::executebeing arbitrary user C++ on a strand.Verification status
Read directly in this repository (master
42deb96), and the basis for everyclaim about morph: the specs and headers listed in §9. Three code-level claims
were checked against source rather than taken from a spec:
include/morph/core/remote.hpp,takeExecuteTicket/awaitExecuteTurn/releaseExecuteTicket);kLenientRead{.error_on_unknown_keys = false}on the generatedfromJson/resultFromJsonpaths (include/morph/core/registry.hpp, foursites);
localOpinvokingmodel.execute(*sharedAction)on the caller's own object(
include/morph/core/bridge.hpp).Update: the live-
executerename claim has since been confirmed byexecution. The original pass did not build or run morph, and reasoned the
rename-decodes-to-default behaviour on the live
executepath fromkLenientReadplus Glaze's name-based matching alone, flagging it as needingconfirmation before anyone designs against it. Triage has since reproduced it
directly against a real
RemoteServer/handle()/BRIDGE_REGISTER_ACTIONbuild (see the correction inline in §4), and independently again on #207 with
its own probe. The claim is confirmed, worse than originally stated (a
zero-shared-keys payload also executes as a real mutation), and bidirectional
(
resultFromJsontoo). #174's own journal-replay repro remains separatelyunreproduced and is #174's concern, not this issue's.
Taken from other projects' published documentation, not from running or
reading their source: everything in §2 and §4–§8 about Qt Remote Objects, gRPC,
Protocol Buffers, Cap'n Proto, DDP, tRPC, ZeroC Ice, and Erlang. Links in §9.
Weakest source. The Waldo et al. 1994 paper itself was not retrievable
during this pass (the canonical PDF returned 403). Its argument is characterised
from the ACM/Springer records, the author's own listing, and secondary
summaries, which agree on the four-axis structure and on partial failure being
the central difference. The short phrases attributed to it in §3.1 should be
re-checked against the paper before being quoted as authoritative anywhere else.
Quotations from Ice, gRPC, protobuf, Cap'n Proto and tRPC come from pages I
fetched directly.