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
Found while fixing #587 in morph::net. Not folded into that PR: it lives in include/morph/qt/qt_websocket_backend.hpp / src/qt/qt_websocket_backend.cpp, which the open PR #585 holds, and it is a different backend.
The finding
QtWebSocketBackend disagrees with itself about whether a private registration carries contextKey:
auto env = ::morph::wire::makeRegister(typeId, std::string{contextKey});
QtWebSocketBackend does not override registerModelWithContext at all. grep -n "registerModel" include/morph/qt/qt_websocket_backend.hpp lists registerModel, registerModelAsync, registerModelShared and registerModelSharedAsync — no registerModelWithContext. So it inherits IBackend's default (include/morph/core/backend.hpp:162), which does (void)contextKey;.
Which of the two runs is decided by QtWebSocketBackendConfig::asyncRegistrationEnabled, and that defaults to false. Bridge::registerHandlerImpl (include/morph/core/bridge.hpp:1799) tries registerModelAsync first; QtWebSocketBackend::registerModelAsync returns false immediately when the flag is off (src/qt/qt_websocket_backend.cpp:161), so Bridge takes the fallback at bridge.hpp:1853:
if (!started) {
binding->currentId.store(
backend->registerModelWithContext(binding->typeId, binding->modelFactory, binding->contextKey).v);
…which is the dropping default.
Two further sites reach the same default:
Bridge::switchBackend's re-registration, bridge.hpp:1308, calls registerModelWithContext directly — so a reconnect/backend swap drops the key even for an embedder that enabled the async flag.
IBackend::bindModel's default dispatches the empty-primary shape to registerModelWithContext (backend.hpp, BindRequest's table), and QtWebSocketBackend does not override bindModel.
Why it matters
Same mechanism as #587. RemoteServer::attachLogIfConfigured (include/morph/core/remote.hpp:704) returns before consulting the LogProvider when the envelope's contextKey is empty:
So this is not "an audit entry missing its entity key". No log is attached, and a privately-registered instance produces no audit record at all. It fails open.
asyncRegistrationEnabled silently changes audit behaviour. Its doc comment (qt_websocket_backend.hpp:42) is entirely about blocking versus not blocking the Qt event loop; nothing says that flipping it is also what decides whether the instance is journalled. An embedder who turns it on for WASM gets journaling as a side effect, and one who leaves it off does not.
backend.hpp:155's own doc comment on the default already states the rule this violates:
"Backends whose model instances live behind a wire protocol (SimulatedRemoteBackend) override this to carry @p contextKey across — see wire::Envelope::contextKey and RemoteServer::setLogProvider."
QtWebSocketBackend is a wire-protocol backend that does not.
Verification status
Inferred from reading the code. Not reproduced. Stated plainly because the distinction matters here: #587's triage was in the same position for morph::net, and when I actually stood the transport up the defect was real and worse than the reading suggested — but that is evidence about morph::net, not about this.
What I did not do: build the Qt transport (MORPH_BUILD_QT=ON) or run anything against a QtWebSocketServer. Every claim about runtime behaviour here is a reading of the dispatch chain, not a measurement.
Corroboration that is already in the tree, and is not my inference:docs/spec/core/backend.md:1215 documents the drop as current behaviour —
"registerModelWithContext is not overridden — the default drops the contextKey, so this transport does not carry a context key to the server's LogProvider."
and tests/test_action_log_phase2.cpp:347 is a passing test pinning the server-side half (makeRegister("P2_Model") with no key ⇒ REQUIRE_FALSE(providerCalled)), which is byte-for-byte the envelope the blocking path sends.
Not measured: whether any shipped rung or example registers privately over QtWebSocketBackend with a non-empty contextKey today. If none does, this is latent rather than active — which changes priority, not validity.
What would close it
The Qt analogue of the test #587's PR added for morph::net (tests/net/test_socket_backend.cpp, "a private registration carries contextKey to the server's log provider"): stand up a QtWebSocketServer over a RemoteServer with a LogProvider installed, register privately through QtWebSocketBackend with a non-empty contextKey, and assert the provider was consulted with that key — with asyncRegistrationEnabled left at its false default, since that is the path in question.
Per AGENTS.md's "would this still pass if the feature did nothing": assert on providerCalled/entityKey, not on the registration succeeding. It succeeds today. That is exactly the assertion that failed for morph::net ({ } == { "SbEchoModel:acct-587" }) while registration itself passed.
The fix is then the same two lines #587 took: override registerModelWithContext to build makeRegister(typeId, std::string{contextKey}), and have registerModel forward to it with an empty key.
What would change the verdict
Close this if the Qt transport is deliberately excluded from journaling private registrations — but then the disagreement with SimulatedRemoteBackend and SocketBackend is the bug instead, backend.hpp:155 needs rewriting, and asyncRegistrationEnabled still must not be what decides it.
Close this if a reproduction attempt shows the key does arrive — in which case my reading of the Bridge fallback chain is wrong and I would want to know where.
Sequencing
Touches files held by the open, blocked PR #585 (qt_websocket_backend.hpp / .cpp) and would need the same docs/spec/core/backend.md edit #587's PR is already waiting on. Should be sequenced after #585 clears.
Found while fixing #587 in
morph::net. Not folded into that PR: it lives ininclude/morph/qt/qt_websocket_backend.hpp/src/qt/qt_websocket_backend.cpp, which the open PR #585 holds, and it is a different backend.The finding
QtWebSocketBackenddisagrees with itself about whether a private registration carriescontextKey:QtWebSocketBackend::sendRegisterAsync—src/qt/qt_websocket_backend.cpp:196— carries it:auto env = ::morph::wire::makeRegister(typeId, std::string{contextKey});QtWebSocketBackenddoes not overrideregisterModelWithContextat all.grep -n "registerModel" include/morph/qt/qt_websocket_backend.hpplistsregisterModel,registerModelAsync,registerModelSharedandregisterModelSharedAsync— noregisterModelWithContext. So it inheritsIBackend's default (include/morph/core/backend.hpp:162), which does(void)contextKey;.Which of the two runs is decided by
QtWebSocketBackendConfig::asyncRegistrationEnabled, and that defaults tofalse.Bridge::registerHandlerImpl(include/morph/core/bridge.hpp:1799) triesregisterModelAsyncfirst;QtWebSocketBackend::registerModelAsyncreturnsfalseimmediately when the flag is off (src/qt/qt_websocket_backend.cpp:161), soBridgetakes the fallback atbridge.hpp:1853:…which is the dropping default.
Two further sites reach the same default:
Bridge::switchBackend's re-registration,bridge.hpp:1308, callsregisterModelWithContextdirectly — so a reconnect/backend swap drops the key even for an embedder that enabled the async flag.IBackend::bindModel's default dispatches the empty-primaryshape toregisterModelWithContext(backend.hpp,BindRequest's table), andQtWebSocketBackenddoes not overridebindModel.Why it matters
Same mechanism as #587.
RemoteServer::attachLogIfConfigured(include/morph/core/remote.hpp:704) returns before consulting theLogProviderwhen the envelope'scontextKeyis empty:So this is not "an audit entry missing its entity key". No log is attached, and a privately-registered instance produces no audit record at all. It fails open.
Two consequences worth stating separately:
SimulatedRemoteBackend(remote.hpp:1849) and — as of net: SocketBackend::bindModel drops contextKey on a private registration, unlike SimulatedRemoteBackend #587 —morph::net::SocketBackendboth do. Backends documented as interchangeable disagree.asyncRegistrationEnabledsilently changes audit behaviour. Its doc comment (qt_websocket_backend.hpp:42) is entirely about blocking versus not blocking the Qt event loop; nothing says that flipping it is also what decides whether the instance is journalled. An embedder who turns it on for WASM gets journaling as a side effect, and one who leaves it off does not.backend.hpp:155's own doc comment on the default already states the rule this violates:QtWebSocketBackendis a wire-protocol backend that does not.Verification status
Inferred from reading the code. Not reproduced. Stated plainly because the distinction matters here: #587's triage was in the same position for
morph::net, and when I actually stood the transport up the defect was real and worse than the reading suggested — but that is evidence aboutmorph::net, not about this.What I did: read the four call sites above on
992b190cplus my net: SocketBackend::bindModel drops contextKey on a private registration, unlike SimulatedRemoteBackend #587 branch, and confirmed bygrepthat noregisterModelWithContextorbindModeloverride exists ininclude/morph/qt/qt_websocket_backend.hpp.What I did not do: build the Qt transport (
MORPH_BUILD_QT=ON) or run anything against aQtWebSocketServer. Every claim about runtime behaviour here is a reading of the dispatch chain, not a measurement.Corroboration that is already in the tree, and is not my inference:
docs/spec/core/backend.md:1215documents the drop as current behaviour —and
tests/test_action_log_phase2.cpp:347is a passing test pinning the server-side half (makeRegister("P2_Model")with no key ⇒REQUIRE_FALSE(providerCalled)), which is byte-for-byte the envelope the blocking path sends.Not measured: whether any shipped rung or example registers privately over
QtWebSocketBackendwith a non-emptycontextKeytoday. If none does, this is latent rather than active — which changes priority, not validity.What would close it
The Qt analogue of the test #587's PR added for
morph::net(tests/net/test_socket_backend.cpp, "a private registration carries contextKey to the server's log provider"): stand up aQtWebSocketServerover aRemoteServerwith aLogProviderinstalled, register privately throughQtWebSocketBackendwith a non-emptycontextKey, and assert the provider was consulted with that key — withasyncRegistrationEnabledleft at itsfalsedefault, since that is the path in question.Per AGENTS.md's "would this still pass if the feature did nothing": assert on
providerCalled/entityKey, not on the registration succeeding. It succeeds today. That is exactly the assertion that failed formorph::net({ } == { "SbEchoModel:acct-587" }) while registration itself passed.The fix is then the same two lines #587 took: override
registerModelWithContextto buildmakeRegister(typeId, std::string{contextKey}), and haveregisterModelforward to it with an empty key.What would change the verdict
SimulatedRemoteBackendandSocketBackendis the bug instead,backend.hpp:155needs rewriting, andasyncRegistrationEnabledstill must not be what decides it.Bridgefallback chain is wrong and I would want to know where.Sequencing
Touches files held by the open, blocked PR #585 (
qt_websocket_backend.hpp/.cpp) and would need the samedocs/spec/core/backend.mdedit #587's PR is already waiting on. Should be sequenced after #585 clears.🤖 Generated with Claude Code
https://claude.ai/code/session_01GS5K2vqZtC4xbRiGJHT7jH