Stateful models: keyed shareable instances, instance subscriptions, and a bank example that demonstrates them (closes #18) - #20
Open
Yaraslaut wants to merge 18 commits into
Open
Conversation
Compares morph against Axelor, Jmix, Causeway and Orleans by use case against examples/bank rather than by feature-list parity. The finding: every bank model is stateless (its only member is the inherited DataMapper), so the per-instance strand protects nothing and a BridgeHandler registering one instance per handler gives the desktop GUI five AccountModel instances for one logical thing. Adds §F to docs/todo.md with three accepted items and a refusal table, and one spec per item under docs/planned/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A model declares a nested PrimaryKey alias (detected structurally, like views.md's kind/query); actions declare which field carries it via BRIDGE_KEY_FROM, or that their result establishes it via BRIDGE_KEY_FROM_RESULT. BridgeHandler<M, AllowShared> joins a server-side directory keyed on (typeId, primary), so two handlers -- in one process or in two clients over one RemoteServer -- reach the same instance. - wire: `primary`/`shared` envelope fields plus `attach`, `assign` and `instances` kinds. All additive; a `shared:false` register is unchanged. - RemoteServer: (typeId, primary) directory with a cross-connection attach count. Shared instances are recorded ownerless, because authorizeInstance's documented ownerPrincipal == ctx.principal policy would otherwise reject every client but the creator. - A7 change: closeConnection now releases one reference per attachment rather than erasing, so one client's disconnect cannot destroy an instance another client still holds. Scope membership became a count for the same reason. - A keyed action re-points the handler; instances never change identity, so a key always maps to one instance and no collision case arises. - Result-sourced keys promote the instance the create ran on (assignPrimary) rather than re-pointing to a fresh one, which would strand the new state. BridgeHandler<Model> is BridgeHandler<Model, NoSharing> and behaves exactly as before; all 770 pre-existing tests pass unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SocketBackend and QtWebSocketBackend implement registerModelShared, attachModel, assignPrimary and listInstances, so cross-client sharing works over the raw-socket and Qt WebSocket transports and not only through SimulatedRemoteBackend. Both keep the same synchronous control-call discipline registerModel already had, and both degrade to the private path on an empty primary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every bank model was stateless -- its only member was the DataMapper inherited from WithMapper -- so the per-instance strand morph advertises as its core service protected nothing, and the example demonstrated the bridge while making the model layer look like a thin RPC shim. AccountModel now holds one account in memory, keyed by account id, hydrated on first use and written through on mutation. CustomerModel takes the per-owner half (ListAccounts/OpenAccount), which was never account-scoped -- the `owner` field on those DTOs was the symptom of one model doing two jobs. Cross-model writes (transfer, bill payment, loan disbursement) settle inside a SqlTransaction owned by another model, so they land behind a cached row's back. bank/db/row_versions.hpp is the smallest honest fix: writers bump a counter, cached readers re-hydrate on a stale version. Documented as the example's sharp edge rather than arranged away. WASM shadow models and the five GUI controllers move with it; the desktop GUI, CLI, and all 21 bank tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anism subscribe<R>(cb) is now keyed on the result/state type and fires whenever an R is produced on the instance the handler is attached to -- by this handler, by another handler sharing the instance, or by another screen entirely. A subscriber names what it renders, not what somebody else must call to produce it, so adding an action that also yields an R never breaks an existing subscriber. Removes the reactive-draft mechanism it supersedes: set<&A::field>, reset<A>, the action-keyed subscribe, and the in-flight coalescing whose subtleties only existed because the draft was remote from its validator. ActionValidator survives with its A1 server-side role; it loses only its draft-readiness one. morph::flows::FlowSession is reworked onto direct dispatch. It already owned its own _drafts tuple and merely mirrored into the handler's, so it now gates on ActionValidator itself and executes the completed step. Public FlowSession API, the w-*/app-* schema, and WizardView.qml are unchanged; all flows/app tests pass untouched. Subscriptions are held against the binding, not a fixed instance id, so a re-pointed handler keeps them -- "tell me about the account I am looking at" keeps working when the user switches accounts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- spec/core/shared_instances.md: the keyed/shared-instance design, moved out of docs/planned and rewritten present-tense now that it ships. - spec/core/bridge.md: "Subscription semantics" rewritten for result-keyed instance subscriptions; SubscriberState, set<>, reset<> and tryFireImpl removed along with the mechanism they described. - ARCHITECTURE.md: "Subscriptions and fielded actions" becomes "Instance subscriptions", with the new behaviour table. - forms/workflows_navigation.md, forms/forms.md: FlowSession dispatches directly, so recomputeAll now has three call sites and all are authoritative -- the client-side display-only one is gone. - examples/bank/README.md: documents the stateful, keyed models and the cross-model staleness edge. - README.md: corrects three "Status & limitations" claims that §A/§B had already invalidated, and states the in-process scope of subscriptions. docs/planned/ is empty again, as its own convention requires. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last call site of the removed reactive-draft API. It now subscribes to PayeeInfo -- the result type -- and asserts the two properties that replaced the draft readiness gate: an incomplete payee is rejected by the dispatch-path validator, and a failed action notifies no subscriber. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
publishResult already skipped an entry whose binding had expired, but only add/removeSubscription ever erased one. A handler destroyed without an explicit unsubscribe therefore left its entry behind until some other handler happened to subscribe -- which in a long-lived app with many transient handlers is never. Prune while already holding the lock and walking the list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The default attachModel releases the current instance before acquiring the new one. If the acquire then failed -- a transport error, a server at maxLiveModels -- the binding kept pointing at the id it had just given up, so the next execute dispatched to a released instance and got a confusing "model not found" instead of the documented "handler not bound". Unbind first and publish the new id only on success. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- model_key.hpp: drop a redundant `typename`, justify the from_chars pointer pair, and wrap the two declaration macros in the same NOLINT(cppcoreguidelines-macro-usage) block registry.hpp already uses -- they must emit a template specialisation at global scope, which no function template can do. - test fixtures: annotate why model/action/result types need external linkage (glaze reflection cannot see into an anonymous namespace), mark const execute overloads [[nodiscard]], and take subscriber values by reference. - customer_model.cpp: a real latent bug moved verbatim from the old account_model.cpp -- QuerySingle's optional was dereferenced unchecked. requireUserId proves the row existed a moment earlier, which is not the same as proving this query found it; it now throws NotFound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Yaraslaut
force-pushed
the
feat/stateful-models
branch
from
July 27, 2026 22:52
c60a91c to
f93ae51
Compare
shared_instances.md is explicit that an instance is shared across clients, so a reader could reasonably assume a result produced by another client on that instance reaches this client's subscribers. It does not: there is no server-initiated frame. Say so where the subscription semantics are defined, not only in todo.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
… race Two things the Valgrind job surfaced. publishResult ran on every successful result, building a std::type_index and copying the result into a std::any before the Completion could resolve -- work paid by every existing caller for a feature they are not using. A relaxed atomic subscription count now short-circuits it. Measured over 200k executes: 537k/s before, ~548k/s on master, i.e. back to parity. The concurrency test's `succeeded > 0` was a coin flip, not an invariant. Valgrind serialises every thread onto one core, so a switcher looping every 1ms can legitimately cancel all 200 in-flight calls and leave zero successes; master passed it by luck. The structural claim it was making -- that the snapshot-and-dispatch path still resolves successfully after repeated switching -- is now asserted against the quiesced bridge, which tests the same property without racing the switcher. The churn assertions (every call resolves, none is lost) are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Yaraslaut
force-pushed
the
feat/stateful-models
branch
from
July 27, 2026 23:06
d44c364 to
38b6e2d
Compare
BRIDGE_KEY_FROM_RESULT and the assignPrimary promotion it drives had no test at all -- a real gap, not just a coverage number. The two new cases pin the property that motivated promoting an instance in place rather than re-pointing to a fresh one: the state the creating action just built is still there afterwards, and the instance is then reachable by its generated key like any other. Covered locally and over SimulatedRemoteBackend, so the `assign` wire verb is exercised too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SocketBackend's registerModelShared/attachModel/listInstances shipped exercised only through SimulatedRemoteBackend, which never touches them -- the wire path for sharing had no coverage at all. Two cases over an actual WebSocket, using a stateful counter because a stateless echo model cannot tell sharing from not-sharing: two separate clients naming one key observe a single counter (15, not 5) and one directory entry, while a plain handler on a third connection registers its own instance and counts from zero. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Unreleased section had no Changed or Removed headings because nothing had needed them yet. This program needs both: A7's closeConnection semantics changed, and the reactive-draft mechanism is a public API removal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…count The wire-protocol section listed three additive changes and omitted `assign` entirely -- the verb that makes a result-sourced key promote in place instead of stranding whatever the creating action just did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three branches the happy-path tests never reached, all driven through the public API rather than a test-only accessor: - a creating action whose generated key collides with a live instance must leave the incumbent's state and directory entry alone (ShiCreateAs lets a test choose the generated id, which is the only way to force this); - re-attaching to the key a handler already holds must not release and re-acquire the instance, which would silently discard its state; - instances() before anything is attached is empty, not an error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Patch coverage showed the new wire surface's server paths were reachable only through the happy path: 47 of the uncovered lines were in remote.hpp. These drive RemoteServer directly, which is the only way to reach the refcount-and-scope interactions that make cross-client sharing safe. Covers the A7 change concretely -- closing one of two connections sharing a key leaves the instance alive and executable for the other, and only the last release destroys it -- plus attach re-pointing, assign promotion, assign declining to displace an incumbent, the instances listing (with a private register correctly absent), empty-typeId rejection for all three new kinds, and a shared register against an already-closed scope. The directory keys on envelope strings, so no keyed model type is needed to exercise it -- that is a client-side concept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Closes #18.
What the survey found
Issue #18 asked what morph is missing versus Axelor, Jmix, Causeway and Orleans. Reading those feature lists side by side produces a ~25-item backlog, most of which morph should never build — it owns neither a database, a process engine, nor an IDE. So the survey was done the other way round: against use cases in
examples/bank, asking where a developer hits a wall.That produced one finding that subsumes most of the others:
Orleans names this directly — a grain is identity + behaviour + state, and morph had only behaviour. The fix is not to become an actor runtime; it is to make the existing model layer do what it already claims.
docs/todo.md§F records the full analysis, including a refusal table so the survey does not get re-run: entity metamodels, BPMN, field-level permissions, clustering, reporting engines and the rest, each with the reason it is out of identity.What shipped
F1 —
examples/bankreshaped onto stateful models.AccountModelholds one account in memory, keyed by account id, hydrated on first use and written through on mutation.CustomerModeltakes the per-owner half (ListAccounts/OpenAccount), which was never account-scoped — theownerfield on those DTOs was the symptom of one model doing two jobs.F2 — Keyed, shareable model instances. A model declares a nested
PrimaryKeyalias (detected structurally, likeviews.md'skind/query); actions declare which field carries it viaBRIDGE_KEY_FROM, or that their result establishes it viaBRIDGE_KEY_FROM_RESULT.BridgeHandler<M, AllowShared>joins a server-side directory keyed on(typeId, primary), so two handlers — in one process or in two clients over oneRemoteServer— reach the same instance.F3 — Instance subscriptions.
subscribe<R>(cb)is keyed on the result/state type and fires whenever anRis produced on the attached instance, by any handler attached to it. A subscriber names what it renders, not what somebody else must call to produce it.Things worth a reviewer's attention
closeConnectionnow releases one reference per attachment rather than erasing. Without this, one client's disconnect destroys an instance another client is still using. Connection-scope membership became a count for the same reason.authorizeInstance's documentedownerPrincipal == ctx.principalpolicy would otherwise reject every client but the creator, defeating cross-client sharing outright. Gating a shared model isauthorize's job or the model's own.set<&A::field>,reset<A>, the action-keyedsubscribe, in-flight coalescing) is gone.FlowSessionalready owned its own draft tuple and merely mirrored into the handler's, so it now gates onActionValidatorand dispatches directly — its public API, thew-*/app-*schema, andWizardView.qmlare unchanged.ActionValidatorkeeps its A1 server-side role. morph is 0.1.0 andVERSIONING.mdreserves exactly this latitude.SqlTransactionowned by a different model, so they land behind a cached row's back.bank/db/row_versions.hppbumps a per-row counter and cached readers re-hydrate on a stale version. morph has no cross-instance transaction and the example must not imply otherwise.Bridge. Two handlers in one process — the case the bank GUI has — see each other's work; two separate clients do not. A server-initiatednotifyframe would need both transports to grow an unsolicited-message path, which is its own item and is recorded as such.Verification
clang-releaseclang-debug+ net + sqlite + load testsgcc-debug+ Qt6 (offscreen QML)examples/bank(SQLite/Lightweight)WARN_AS_ERROR=FAIL_ON_WARNINGSclang-tidy-diffon changed linesscripts/check_spec_citations.shNew tests pin the new semantics:
tests/test_shared_instances.cpp(18 cases,including result-sourced keys and in-place promotion), two real-socket cases in
tests/net/test_socket_backend.cppcovering the sharing wire path, andexamples/bank/tests/test_stateful_account.cpp(4 cases).tests/test_subscription.cppwas rewritten for the new subscription meaning.Two things CI caught that were worth fixing properly
A throughput regression on the hot path.
publishResultran on everysuccessful result — building a
std::type_indexand copying into astd::anybefore the
Completioncould resolve — work every existing caller paid for afeature they aren't using. A relaxed atomic subscription count short-circuits
it; measured over 200k executes, throughput is back to parity with master
(~537k/s vs ~548k/s, within noise).
A pre-existing flaky assertion, not a weakened one. The Valgrind job failed
on
test_concurrency_invariants.cpp'ssucceeded > 0. Valgrind serialises everythread onto one core, so a switcher looping every 1ms can legitimately cancel all
200 in-flight calls and leave zero successes — master passed that line by luck,
not by invariant. The structural property it was asserting (the
snapshot-and-dispatch path still resolves successfully after repeated switching)
is now checked against the quiesced bridge, which tests the same thing without
racing the switcher. The churn assertions — every call resolves, none is lost —
are unchanged.
Docs follow the repo's convention:
docs/planned/is empty again, the keyed-instance design landed asdocs/spec/core/shared_instances.md, and the rest folded intobridge.md,ARCHITECTURE.md,workflows_navigation.md,forms.mdand the bank README.README.md's "Status & limitations" also loses three claims that §A/§B had already invalidated.🤖 Generated with Claude Code