Skip to content

core+qt: no client-side model/action discovery -- adding a model server-side still requires a client rebuild #829

Description

@Yaraslaut

Follow-up from #793/#828 (the generic GenericModelBridgeCore work): a QML
client today has zero way to learn what models or actions the backend
exposes without being compiled against them. The user's framing: they want
to add a model server-side and have "all introduced models... automatically
served and exposed and understood by the client app" — a workspace the
client discovers, not a fixed set it was built knowing about.

Goal

The concrete target this is aiming at: one generic client binary usable
against any of the seven ladder rungs
(pastebin, polls, bookmarks, kanban,
ledger, lims, crm) without being rebuilt per rung — point it at a different
server and it discovers and renders whatever that server exposes. This is
explicitly the stated end goal, not just "nicer discovery."

That target has two distinct gaps, addressed in the two sections below:

  1. Dispatch coupling — a client can't talk to a model it wasn't compiled
    against at all, even a schema-driven one (original scope of this issue).
  2. Bespoke-UI coupling — even with (1) solved, a generic client can only
    ever be as capable as its generic renderer (DynamicForm.qml plus a
    generic list/query view). Anything a rung does that isn't plain
    schema-driven CRUD — kanban's drag-drop board, bookmarks' multi-model
    dispatch (qt/forms: BookmarkFormsController hand-rolls multi-model action routing that no core template supports #827), any rung's custom offline-queue/sync-status display — has
    no generic equivalent today, by design (examples: where the 42k lines of application code go, and the four building blocks that would remove most of it #793 measured that this curation
    code doesn't generalize). Section "Filling the bespoke-UI gap" below is a
    candidate answer to this second gap, added after this issue was filed.

What exists today, and what's missing — verified directly against the headers

Schema discovery exists, but only per-already-known-model, and only over
Remote.
ActionDispatcher::schemasJson(modelId)
(include/morph/core/registry.hpp:880) returns the full
{actionType: schema} document for one model — but it takes modelId as a
parameter the caller must already have. There is nothing upstream of it that
answers "what models exist" — confirmed by grepping the whole framework for
any listing/enumeration surface:

$ grep -rn "listModels\|registeredModels\|allModels\|ModelIds\|modelIds()\|enumerateModels" include/morph/
(zero matches)

Dispatch itself requires a compile-time Model type — schema discovery
alone would not be enough.
Every handler-shaped public method on
morph::bridge::Bridge is a member template on Model
(registerHandler<Model>, attachHandler<Model>, attachHandlerAsync<Model>,
assignHandlerPrimary<Model>, listInstancesOf<Model>, the
BRIDGE_REGISTER_ACTION-registered <Model, Action> pair) — confirmed by
listing every public method signature in the class body
(include/morph/core/bridge.hpp, class Bridge { ... }, ~230 lines
surveyed). There is no raw, string-only "dispatch this actionId against this
modelId" entry point on Bridge itself. The actual dispatch path,
ActionExecuteRegistry::execute<Sharing>(modelId, actionId, handler, bodyJson)
(bridge.hpp:97-111), takes a type-erased void* handler, but that pointer
must have been produced by constructing a concrete
BridgeHandler<Model, Sharing> for that exact Model — its own doc comment
says so explicitly ("Type-erased BridgeHandler<Model, Sharing>* matching
modelId"). So even a client that fetched a model's full schema at runtime
could not actually invoke an action on it unless the client binary was
already compiled with a BridgeHandler<ThatModel> — the schema tells it
what to send, not how to send it.

Why this is the real blocker, not a UI problem

The rendering half of this is already solved generically: DynamicForm.qml
is one framework-owned component that draws any schema it's handed, with no
per-model QML anywhere in the tree (established while investigating #793).
The gap is entirely at the dispatch layer described above — a model
introduced on the server today requires a client rebuild (a new
BridgeHandler<NewModel> instantiation, wired into a new or existing
FormsControllerCore<NewModel>/GenericModelBridgeCore<NewModel>) no matter
how good schema discovery gets.

Filling the bespoke-UI gap: server-delivered QML modules

Raised alongside the Goal above: even if dispatch coupling (gap 1) is fully
solved, the client's UI ceiling is still "whatever the generic renderer can
draw." One candidate to close gap 2 as well: have the server ship the
bespoke QML for a rung's non-generic parts (e.g. kanban's board view), and
have the client load it at runtime — Qt supports instantiating QML from a
string or a URL at runtime (QQmlComponent/Loader.source can point at
network content), so the mechanism has real prior art in Qt itself, even
though nothing in morph today does this — this is a new capability, not
an extension of an existing seam like schemasJson is for gap 1.

This is a materially bigger change than gap 1, with its own open questions,
not yet designed:

  • Trust boundary, categorically different from JSON discovery. A schema
    document is inert data the client's own fixed renderer interprets; a
    server-delivered QML file is executable code (QML plus embedded
    JavaScript) the client would run. docs/spec/forms/forms.md's trust-model
    language ("the server never trusts the client") is about the opposite
    direction — nothing in the codebase today establishes what a client should
    trust from a server. This needs its own explicit design, not an
    extension of the forms trust model.
  • Client/server version compatibility. Server-authored QML would need to
    target whatever QML/Qt runtime version the connecting client actually has;
    a mismatch is a runtime failure, not a compile-time one.
  • Caching/offline story. Every rung's offline queue and sync worker
    (morph::offline) assume the client's own logic is always available
    locally; runtime-fetched UI code raises the question of what happens
    offline before it's ever been fetched once.
  • Review/provenance. Unlike a schema (validated structurally against
    ActionTraits<A>), there's no proposed mechanism for a client to validate
    that server-delivered QML is the version/author it expects.

Not scoped further here — flagging it as the natural second half of this
issue's Goal, to be designed separately (likely its own issue) once gap 1
has a direction, since the trust-boundary question above may turn out to be
the harder problem of the two.

Verification status

Directly verified (not inferred): the absence of any model-enumeration
API (grep above, zero matches across include/morph/), and that every
Bridge handler-registration method is a Model template (read the full
class Bridge body in include/morph/core/bridge.hpp).

Not verified / open questions this issue does not answer:

  • Whether a type-erased dispatch path is feasible at all without breaking
    ActionExecuteRegistry's Sharing-keyed lookup (bridge.hpp:65-79) — no
    design attempted.
  • Whether ActionDispatcher::schemasJson(modelId)'s Remote-only restriction
    is a deliberate trust-boundary decision or simply unimplemented for Local
    mode — not checked against docs/spec/core/bridge.md's "Design decisions"
    table.
  • Whether a "list registered models" endpoint belongs on ActionDispatcher
    (server-side directory, mirroring schemasJson) or needs a new registry
    entirely — no design attempted.
  • What a client does with a newly discovered model at runtime without any
    compiled type for it — this likely requires the client to operate purely
    on JSON (schema-in, JSON-body-out) for undiscovered models, which is a
    different, weaker contract than the typed BridgeHandler<Model> path
    gives today, and needs its own design discussion about what capabilities
    (validation, offline queueing, typed results) a "fully dynamic" model
    loses relative to a compiled one.
  • Whether server-delivered QML (above) is even the right answer to gap 2, or
    whether a richer, still-declarative view/layout vocabulary (extending
    morph::views/docs/spec/forms/views.md rather than shipping executable
    code) could cover kanban/bookmarks-shaped bespoke UI without opening an
    execution trust boundary at all — not evaluated against each other.

What would change the verdict

Close as not worth building if a design pass concludes the typed
BridgeHandler<Model> requirement is load-bearing enough (e.g. for the
offline queue, replay ledger, or shared-instance directory) that a truly
type-erased dispatch path would have to reimplement or bypass those
subsystems — at which point "the client rebuilds when a model is added" may
be the right tradeoff, and the issue becomes "make that rebuild as small as
GenericModelBridgeCore's macro-free composition already makes it" rather
than "eliminate it."

Re-scope to "schema discovery only" (dropping the client's ability to talk
to genuinely new models without a rebuild) if a full type-erased dispatch
design turns out to conflict with the trust-boundary or sharing-policy
invariants above.

Split the "server-delivered QML" idea into its own issue once gap 1 has a
direction, rather than let it block on or get bundled with the dispatch-layer
work above — they are separable designs with different risk profiles.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreSubsystem: coreenhancementNew feature or requesttriage: rescopeReal problem, wrong framing; rewrite before building

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions