net: carry contextKey on SocketBackend's private registration (fixes #587) - #595
Merged
Merged
Conversation
…587) `SocketBackend` left `IBackend::registerModelWithContext` unoverridden, so its default dropped `contextKey`, and the native `bindModel` path added in #586 dropped it again by omission at the `wire::makeRegister` call site. The consequence is stronger than "a log missing its entity key". `RemoteServer::attachLogIfConfigured` returns *without consulting its `LogProvider` at all* when the envelope's `contextKey` is empty, so an instance registered privately over `morph::net` was not journalled -- no audit record -- while the same registration over `SimulatedRemoteBackend` was. It failed open. `backend.hpp`'s own doc comment on the default already states the rule: backends whose instances live behind a wire protocol override this to carry the key across. `SocketBackend` is such a backend and did not. Both edges now do: - `bindModel`'s private branch passes `request.contextKey` to `makeRegister`; - `registerModelWithContext` is overridden, mirroring `SimulatedRemoteBackend::registerModelWithContext`, and `registerModel` forwards to it with an empty key. `registerModelShared` and `attachModel` degrade to `registerModelWithContext` when `primary` is empty, so their private paths are fixed with it. The shared and attach shapes already carried the key and are untouched, as is `registerModel`, which has no key to send. The obsolete comment at the `bindModel` call site explaining the drop is removed. Verification: reproduced end-to-end over a real socket, not inferred. The new test in `tests/net/test_socket_backend.cpp` stands up a `SocketServer` over a `RemoteServer` with a `LogProvider` installed and asserts the provider was consulted with the key -- and that the log it returns records the executed action under that `entityKey`. With the fix reverted it fails on exactly those assertions (`{ } == { "SbEchoModel:acct-587" }`, `0 == 1` entries, and `{ } == { "SbEchoModel:acct-blocking" }`); registration itself succeeded both before and after, which is why nothing asserts on that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GS5K2vqZtC4xbRiGJHT7jH
…ifted The `default:` disposition in `scripts/branch_partial_allowlist.json` pins a line number as a hint; the fix above moved that label from 813 to 826. The gate's own message says the disposition itself is still sound -- "The text still matches, so nothing is wrong with the disposition -- update the `line` hint" -- and the pinned `source` text (`default:`) is unchanged, so this is a hint refresh, not a new or widened suppression. No entry is added, removed or reworded. Verified: `sed -n '826p' include/morph/net/socket_backend.hpp` prints `default:`, and the file still parses as JSON. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GS5K2vqZtC4xbRiGJHT7jH
This was referenced Sep 19, 2026
Three spots in docs/spec/core/backend.md stated, correctly before this branch and incorrectly after it, that `SimulatedRemoteBackend` is the only backend overriding `registerModelWithContext`. - The `IBackend` method table now names both wire backends, and says why the override is not cosmetic: `attachLogIfConfigured` skips the `LogProvider` lookup entirely on an empty key, so dropping it leaves the instance with no action log rather than a log missing a field. - `SocketBackend`'s API reference gains a `registerModelWithContext` row, `registerModel` becomes the empty-key forwarder it now is, and `bindModel`'s row records that every shape carries `request.contextKey`, the private one included. The empty-`primary` degrades of `registerModelShared`/`attachModel` are named where they land. - The design-decisions row for the permissive default says what the permissiveness costs, since it is what let this ship unnoticed. Scope held to what this branch establishes. Nothing here touches `bindModel`'s blocking/non-blocking question (morph#593) or `QtWebSocketBackend`, whose own drop is real, unchanged, and filed as morph#594 -- `:1215` still describes it accurately. Verified: `scripts/check_spec_sync.sh` over this branch's file list reports "Spec sync OK: 10 sub-domain(s) classified", and `scripts/check_spec_citations.sh` reports "Prose lint OK" with 837 references and 73 cited sections scanned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GS5K2vqZtC4xbRiGJHT7jH
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #587.
What was wrong
morph::net::SocketBackendis a wire-protocol backend that droppedcontextKeyon private registration, on both of its edges:bindModelpath added by net: put SocketBackend on the structural registration surface natively #586 calledwire::makeRegister(request.typeId)— the key discarded by omission at the call site,makeRegister's second parameter being defaulted;registerModelWithContext, so it inheritedIBackend's default, which does(void)contextKey;.The shared and attach shapes (
makeRegisterShared,makeAttach) already carried it, in the same function. Only the private shape was affected.Why it matters more than the title says
RemoteServer::attachLogIfConfigured(include/morph/core/remote.hpp:704) returns without consulting theLogProviderat all whenenv.contextKeyis empty:So this was not "an audit entry missing its entity key" — no log was attached, and the instance produced no audit record at all, while the identical registration over
SimulatedRemoteBackend(which does override the verb,remote.hpp:1849) produced one. Two backends documented as interchangeable disagreed, and the one that disagreed failed open.The fix
include/morph/net/socket_backend.hpp:bindModel's private branch now sendsmakeRegister(request.typeId, request.contextKey), and the comment that used to explain the drop (and point at net: SocketBackend::bindModel drops contextKey on a private registration, unlike SimulatedRemoteBackend #587) is gone with it;registerModelWithContextis overridden, mirroringSimulatedRemoteBackend::registerModelWithContext. It reuses this backend's existingsendControlForId(env, "register"), whose body is byte-for-byte what the old inlined one did, so the error strings ("register failed: <msg>","register failed: disconnected") and the one-synchronous-call-at-a-time constraint are unchanged;registerModelforwards to it with an empty key.This is not a judgement call:
backend.hpp:155's doc comment on the default already states the rule — "Backends whose model instances live behind a wire protocol (SimulatedRemoteBackend) override this to carry @p contextKey across."git log -Son the call site returns only70792bee(#586), so the drop was a knowingly-preserved pre-existing gap, not an old deliberate decision this PR is undoing. #586 was right not to fold it in.Also fixed, by routing:
registerModelSharedandattachModelboth degrade toregisterModelWithContextwhenprimaryis empty. Those are private registrations too, and they dropped the key for the same reason; they now carry it. Nothing else about those verbs changes.Verification — reproduced, not inferred
The issue and its triage comment were both explicit that the end-to-end path had never been reproduced: the server-side half was pinned by
tests/test_action_log_phase2.cpp:347, but the transport half was read, not run. That is what the new test intests/net/test_socket_backend.cppdoes — a realSocketServerover aRemoteServerwith aLogProviderinstalled, and a realSocketBackendclient over a real TCP connection.Measured with the test applied and the fix reverted (
include/morph/net/socket_backend.hpprestored to its992b190cstate), Clang 22.1.8, Debug,-DMORPH_BUILD_NET=ON:With the fix applied:
The "would this still pass if the feature did nothing" check. Registration succeeded before the fix and succeeds after, so nothing in the new test asserts on it. Every assertion is on
requestedFor(was the provider consulted, and with which key) or on the journal entry'sentityKey— which is precisely what failed above. ThebindModeledge and theregisterModelWithContextedge are separateSECTIONs, so reverting either one alone fails its own section rather than being masked by the other. A third section pins the negative: plainregisterModelstill sends no key and the provider is still not consulted, so the fix did not simply start stamping a key onto everything.Full local runs, Clang 22.1.8, Debug,
MORPH_BUILD_NET=ON,MORPH_BUILD_TESTS=ON:morph_net_tests:All tests passed (1111 assertions in 190 test cases)morph_tests:test cases: 1516 | 1515 passed | 1 failed as expected,assertions: 22461 | 22460 passed | 1 failed as expected(the expected failure is the pre-existingdefault-sink-coverageshouldfailcase)ctest -j4:100% tests passed out of 1708clang-tidy-diff.pyoverorigin/master...HEADwith CI's flags (-extra-arg=-std=c++23 -extra-arg=-Wno-missing-include-dirs, clang-tidy 22.1.8): zero findings. That result is evidence rather than a filter artifact — a control run of the same binary with--header-filteraimed at the changed header reports 17 findings inside it (all pre-existing, on unchanged lines, which the line filter drops), so the header was genuinely analysed and my new lines genuinely produced nothing.clang-format(22.1.8, repo.clang-format) applied; tree clean under it.Review reasoning
Copilot review is unavailable on this org (zero seats), so this section is the review.
registerModel's observable contract is identical:sendControlForId(env, "register")produces the same two error strings from the same two branches as the body it replaces, which the existing disconnect/error cases intests/net/test_socket_backend.cppcover and which still pass.makeRegister'scontextKeyalready round-trips throughencode/decode(pinned attest_action_log_phase2.cpp:292), andRemoteServer'sregisterhandler already reads it.env.session = currentSession()before sending, as every other control verb here does;sendControlAsyncstamps it itself, so thebindModelbranch was already covered.contextKeymakes is theLogProviderlookup — which is the point — so a caller that never set one is bit-for-bit unaffected. The new test's third section is the standing check on that.registerModelWithContexttakes the samesendSynctokenregisterModelalready took;bindModelstill never enterssendSync, so the propertydocs/spec/core/backend.md's deadlock argument rests on ("a native control call never parks on_syncCv") is unchanged. The existing[registration-surface]tests that pin it still pass.bridge.hppseam is not touched. This change is entirely atSocketBackend's own call sites and goes nowhere nearBridge::registerHandlerImpl, so core: #567's registration surface gives Bridge no way to tell a natively non-blocking backend from a blocking one, and two shipped backends now need opposite behaviour at that one call site #593 is neither widened nor depended on.-Wdocumentationwith-Werroris on: the new override'sfactoryparameter is unnamed, so it carries no@param factory— the same conventionSimulatedRemoteBackend's override follows. The build caught a first draft that did, and it is fixed.Spec
docs/spec/core/backend.mdis updated in its own commit. Three spots stated —correctly before this branch, incorrectly after it — that
SimulatedRemoteBackendis the only backend overriding
registerModelWithContext:IBackendmethod table. Now names both wire backends, and says why theoverride is not cosmetic:
attachLogIfConfiguredskips theLogProviderlookup entirely on an empty key, so dropping it leaves the instance with no
action log rather than a log missing a field.
SocketBackend's API reference. Gains aregisterModelWithContextrow;registerModelbecomes the empty-key forwarder it now is;bindModel's rowrecords that every shape carries
request.contextKey, the private oneincluded. The empty-
primarydegrades ofregisterModelShared/attachModelare named where they land.
permissiveness costs, since it is what let this ship unnoticed.
Scope deliberately held to what this branch establishes: nothing here touches
bindModel's blocking/non-blocking question (#593), and:1215— "registerModelWithContextis not overridden", about
QtWebSocketBackend— is left alone because it isstill accurate. That gap is real and separate, filed as #594.
Verified:
scripts/check_spec_sync.shover this branch's file list reportsSpec sync OK: 10 sub-domain(s) classified, andscripts/check_spec_citations.shreports
Prose lint OKacross 837 references and 73 cited sections.Collision note. PR #585 also edits
docs/spec/core/backend.mdand is blockedon #593. The coordinator lifted the hold on this file deliberately rather than
stall correct work behind a blocked PR, and is recording the collision on #585 so
its next rebase expects it. #585 already reconciled this same file once against
#586.
Filed, not folded
QtWebSocketBackendhas the same gap on its blocking private registration and disagrees with its own async path, which does carry the key. BecauseasyncRegistrationEnableddefaults tofalse, the default configuration of the Qt transport does not journal private registrations — and that flag, documented purely as a blocking/non-blocking choice, is what silently decides it. Filed as inferred from reading the code, not reproduced (I did not build the Qt transport), with the corroboration that is already in the tree named explicitly. It lives in qt: move QtWebSocketBackend onto the structural registration surface #585's files, so it needs sequencing after that PR.Also in this branch
scripts/branch_partial_allowlist.json— thedefault:coverage disposition forsocket_backend.hpppins a line number as a hint, and the header edit moved thatlabel from 813 to 826. The gate's own message says the disposition itself is
sound ("The text still matches, so nothing is wrong with the disposition —
update the
linehint") and the pinnedsourcetext is unchanged, so this is ahint refresh, not a new or widened suppression. No entry is added, removed or
reworded;
sed -n '826p'on the header printsdefault:.Conflicts
CHANGELOG.mdwith the render/forms: match the negative sign as locale data, not as the byte '-' (fixes #583) #598 lane's entry under the same### Fixedheading —resolve additively, keeping both.
docs/spec/core/backend.mdwith qt: move QtWebSocketBackend onto the structural registration surface #585, as described above.🤖 Generated with Claude Code
https://claude.ai/code/session_01GS5K2vqZtC4xbRiGJHT7jH