polls — rung 3 of the application ladder
Status: shipped — every rung-3 task is complete; see
Definition of done for what that does and does not
mean, and "The client, and its known gaps"
for what the shipped client cannot reach (there is no native desktop entry
point at all, so the live multi-client demo the DoD asks for has not been
run; the WASM client is written and CI-gated but has never been compiled
here). Design decisions below were resolved in writing before implementation
began, per LADDER.md's discipline rule.
Research done ahead of writing this rung's implementation plan surfaced two places where this README's own framing does not match the framework as it actually exists, plus decisions the README named but left open. Recorded here, in writing, before any task starts — the discipline rule this ladder runs on.
-
session::Principalis not a capability-token mechanism — correction. This README originally described participant identity as "the participant token insession::Context...session::Principal(added in #34) carrying a capability token instead of a user identity." The realsession::Principal(docs/spec/session/session.md) is a client-side,Bridge-scoped UI cache populated after login from server-returned data — it has no wire representation and does not participate in dispatch authorization at all ("Setting aPrincipaldoes not affectContextor dispatch behavior in any way"). There is no existing framework mechanism for a bare shared-secret-per-entity capability token. Resolved shape:Context::tokencarries the poll's admin secret;PollModel::execute()verifies it itself, by comparing against the poll row's storedadminTokencolumn — the same shape as aSigningAuthorizer-verified token, but hand-verified in the model rather than by anIAuthorizer, since no framework authorizer verifies bare shared secrets.Context::principalcarries the free-textparticipantNameSubmitVotesalready names as an action field.UndoLastVoteChange's "principal-scoped" therefore means keyed on(pollId, participantName), not a framework-authenticated identity.What shipped, stated exactly (corrected after the final whole-branch review found this section overclaiming):
FinalizePollis the only token-gated action inPollModel.SubmitVotes,UpdateVotes,AddComment,UndoLastVoteChange,GetPollState,GetEventsSinceand the keyedOpenPollattach are all reachable by anyone who can name thepollId, with no token check at all — which is the intended design, not a gap:pollIdis 16 bytes ofstd::random_deviceentropy in base64url, so knowing it is the capability (design decision 2 says as much: "attaching to a poll by id is meant to be as open as knowing the link"). A participant gate would add no authority in any case, since one participant token is minted per poll, not per participant, and every voter would present the same secret.CreatePollResult::participantTokenis accordingly generated, stored, returned and shown byCreatePollView.qml— and verified by nothing; it is reserved for a later rung wanting a second, separately revocable capability level. An earlier draft carried aPollModel::requireParticipant()helper with no call sites; it was removed rather than left implying a check that does not happen. -
Registration identity applies to shared/keyed registration too, not just plain registration.
registerModelShared/attachModel's wire form is still aregisterenvelope (docs/spec/core/shared_instances.md: "registergrowsprimaryandshared" — additive, same envelope kind), andwire::makeRegisterSharedcarries the caller's session, just like plainwire::makeRegisternow does. SoauthorizeRegistercould gateOpenPoll{pollId}(the keyed attach) by admin/participant identity — this rung deliberately doesn't. Resolved shape:authorizeRegisterstays unconditionally permissive forPollModel(attaching to a poll by id is meant to be as open as knowing the link, by design — this is not a regression, and not something the framework forces), and the one action that must distinguish admin from participant (FinalizePoll— in the shipped rung, the only one that does) re-checks the caller's token against the poll row's ownadminTokencolumn insidePollModel::execute(). This mirrors rung 2's shape for a different reason than it originally did: bookmarks'authorizeInstanceis now genuinely enforcing but checks instance ownership, whichPollModel(shared/keyed, not per-caller-owned) has no equivalent of at all — so the model's own re-check was never standing in for a defeated hook, it is simply the only layer that could ever express this distinction. -
Undo is entirely app-level; the framework journal contributes nothing to it.
SessionLog::undoLast()(docs/spec/journal/journal.md) "pops the most recent entry and replays the remainder against a fresh, detached model instance" — no principal filtering, and the returned holder cannot be installed into a live shared instance. This is not a bug to work around at the call site; the framework's own journal design record states plainly that "reversing a checkpointed action durably needs a compensating action" at the app level. Resolved shape:PollModelowns a small per-(pollId, participantName)vote-history table of its own (not the framework'sFileActionLog/journal), andUndoLastVoteChangereads and reverses the caller's own most recent entry from it via ordinary mutation. The framework journal remains wired for audit-trail purposes (same two-independent-write default every single-row action in rung 2 used) but is orthogonal to undo. -
GetEventsSinceis genuinely new work, not aGetChangesSinceport. Rung 2'sGetChangesSinceis a timestamp-diffed-current-state view (WHERE updatedAtMs > since OR (updatedAtMs = since AND id > lastId)— theidtie-break is issue #43's fix for the millisecond-boundary case a bareupdatedAtMs > sincecan silently drop; still a current-state view, returning full current rows, not a log) — not the Zulip append-only event-log pattern this rung's own "morph subsystems exercised" section correctly calls for. Resolved shape: a genuinepoll_eventstable (sequence id + payload per mutation), with a table-wide monotonic autoincrement sequence id, not a timestamp — rung 2'sBulkEdit/MergeTagsidempotency-key fix rounds (Tasks 8/9) both hit millisecond-collision bugs from timestamp-keyed uniqueness; an autoincrement primary key sidesteps that class of bug entirely, and the README's own requirement ("a client holdinglastEventId=42... sees nothing new forever, silently") is exactly what a durable, never-reused sequence id guarantees. The "and/or epoch token" alternative the original strain-point text offered is resolved to: not needed. Durable SQLite persistence of the event log alone already closes the gap (an in-memory-only list dying at refcount zero) the epoch token existed to catch; a poll's row-level data plus its event table both survive instance rebirth by construction once persisted, so a reborn instance naturally continues the same global sequence with no separate epoch concept to design, test, or explain.GetEventsSince{lastEventId}returns every event withid > lastEventIdfor the poll, oldest first; an empty poll's-worth of history (a truly stale cursor, e.g.lastEventIdfar beyond the table's current max) is handled the same way any over-advanced cursor is — see the model task for the exact response shape. -
messagesPerSecondis not a framework gap — already implemented.QtWebSocketServerConfig::messagesPerSecond(docs/spec/core/backend.md) is a real, shipped, separately-tested per-connection token bucket. A frame that finds an empty bucket is refused, and answered: it never reachesRemoteServer, and the sender receives anerr "rate limited"addressed to that frame's owncallId(morph#225 —src/qt/qt_websocket_server.cpp'smakeErr("rate limited", …)call, andinclude/morph/qt/qt_websocket_server.hpp's own doc comment). It used to be dropped silently; that is what made a rate-limited call hang until the client-side deadline fired, and it is no longer the case. This rung's own "run this rung's harness withmessagesPerSecondconfigured ON" is a test-harness configuration decision, not new framework work — the client-side execute-deadline prerequisite below is what actually needs building; the rate limiter it must survive already exists. -
CreatePollruns from the native/desktop client only — a product-UX decision, and nothing more. This entry originally carried a second, framework-level justification, and that half was wrong twice over; it is struck rather than quietly dropped, since thenativeClientgate ingui/qml/Main.qmlstill points here.The struck claim was:
Bridge::assignHandlerPrimary's promote step has no async path (IBackend::assignPrimarybeing a synchronoussendSynconQtWebSocketBackend, "with noassignPrimaryAsyncanywhere in the tree"), so a WASM tab dispatchingCreatePollwould abort the page at the promote step. Both premises are false against the tree as it stands:assignPrimaryAsyncexists, at every layer the claim named:IBackend::assignPrimaryAsync(include/morph/core/backend.hpp),QtWebSocketBackend::assignPrimaryAsync(src/qt/qt_websocket_backend.cpp), andassignHandlerPrimaryitself, which prefers it and falls back to the synchronous call only for a backend that offers none (include/morph/core/bridge.hpp).- The promote step never runs for this rung anyway.
assignHandlerPrimaryis reached from exactly one branch ofBridgeHandler::execute, guarded bykShared && ResultKeyed<Action>(include/morph/core/bridge.hpp).CreatePollis neither: it carries noBRIDGE_KEY_FROM_RESULTdeclaration (models/poll_model.hpp's registration block ends at a plainBRIDGE_REGISTER_ACTION), and it dispatches throughPollPresenter::_creator, a plainNoSharingBridgeHandler<PollModel>.OpenPoll— payload-keyed, on theAllowSharedhandler — is this rung's only keyed action, and it takes the attach branch, not the promote branch.
What survives, and is the whole of the justification now: this matches Rallly's own anchor UX (an organizer creates via the main app/site; participants open a shared link in whatever browser tab they have), so the rung's own design wants the split independently of any framework property. Every WASM tab's role is the participant-attach story (
OpenPoll, payload-keyed, covered by the prerequisite work below), never poll creation.The
nativeClientgate stays, with that restated reason. Removing it would be a behaviour change to a shipped client, made on the strength of a docs correction, and it would not actually give anyone a working organizer path — the browser client is this rung's only compilable GUI binary, but it has never been compiled either, and the desktop entry point that would exercise the other side of the split does not exist at all. The gate now rests on product UX alone; it is no longer defended by any framework hazard, and nothing below should be read as claiming otherwise. -
OpenPoll::pollIdis a plainstd::string— and that is now this rung's own unmigrated state, not a framework restriction.This entry originally read: "
OpenPoll::pollId(and any field aBRIDGE_MODEL_KEY/BRIDGE_KEY_FROMmacro deduces a key type from) must be plainstd::string, not a strong type", becausemorph::model::ModelKey"requires an exactstd::same_as<K, std::string>orstd::integral<K>match". The framework no longer says that, and stating it here asserted that the framework forbids what another rung does.include/morph/core/model_key.hppdefinesWrappedModelKeyalongsideRawModelKey— "key types that wrap aRawModelKey— the ladder's strong ids", deduced structurally fromhasValue(), anoperator*yielding a raw key, and constructibility back from that raw value.ModelKeyis the union of the two.limskeysSampleModelonlims::SampleIdthrough exactly that path (BRIDGE_MODEL_KEY(lims::SampleModel, lims::OpenSample, &lims::OpenSample::sampleId)), and the widening (morph#163) let morph#183 delete three rungs' hand-writtenModelKeyTraitsspecialisations.Current state, stated as such:
polls::OpenPoll::pollIdis still a plainstd::string, and this rung has not been migrated to apolls::PollIdstrong id. Nothing prevents it any more. Until it happens, the field sits in the same natural-string-identity categoryIMPLEMENTATION.mdrule 3 already carves out for URLs and titles — a shareable link identifier generated once server-side as an unguessable random token (mirroring the admin/participant tokens' own generation), never user-typed. That is an accurate description of a plain field, not a justification for keeping it plain: migratingpollIdto apolls::PollIdstrong id is named follow-up work. Every other identity field this rung defines (OptionId,PollEventId) is already a strong type, per the usual rule.
Framework prerequisites (built as part of this rung, before the app tasks that depend on them consume them)
Two items LADDER.md's "Framework prerequisites" section named as blocking
this rung specifically. Both were open when this rung started and both have
since shipped — this rung built them, as the heading above says. They are
kept here because they explain the rung's task order, with a pointer to where
each now lives:
- Async shared/keyed attach. Shipped:
IBackend::registerModelSharedAsyncandIBackend::attachModelAsync(include/morph/core/backend.hpp), dispatched to byBridge::ensureBoundAsync/attachHandlerAsyncand implemented byQtWebSocketBackend. The synchronousregisterModelShared/attachModelstill nest aQEventLoopand still abort the page on the WASM main thread, so a WASM client must use the async pair — but it exists, and the very firstOpenPolla WASM tab makes is no longer blocked on the framework. Built as this rung's first framework-level task, mirroringregisterModelAsync's existing opt-in/fallback shape (backend returnstrueand later invokes exactly one callback, or returnsfalseand the caller falls back to the synchronous path unaffected) so every backend that has not opted in keeps its current behavior. - Client-side execute deadline. Shipped:
Bridge::setExecuteDeadline(include/morph/core/bridge.hpp), specified indocs/spec/core/completion.md. Without it a genuinely hung server blocked the callingCompletionforever. (A frame refused bymessagesPerSecondused to belong here too; it no longer does, since the transport now answers it with anerr "rate limited"— morph#225.)Completion<T>::state()already exposes the underlyingCompletionState, andCompletionState::setExceptionis idempotent-guarded (if (ready) return;), so the fix needs noCompletion/CompletionStateAPI changes — only a new client-side timer that races a delayedsetException(ClientTimeoutError)against the real reply. Built as this rung's second framework-level task, before the polling helper (GetEventsSinceon a client timer) that is untestable without it.
Group scheduling polls, Doodle-style: create a poll with candidate dates, send one link to participants, everyone votes yes / if-need-be / no, the organizer finalizes a date. The first genuinely concurrent multi-client rung: many participants converge on one shared poll instance.
- Rallly (TypeScript, Next.js +
tRPC + Prisma, AGPL) — the anchor. Its tRPC procedures are already typed
request/response actions, and the codebase verifiably contains no
websockets/SSE/socket.io at all: concurrent voters see each other's votes
on refetch. It is living proof this category needs no push. Data model to
copy (from
packages/database/prisma/models/):Poll,Option,Participant,Vote (yes|ifNeedBe|no),Comment. Ignore the SaaS billing/licensing packages entirely. - Framadate — archived; do not use.
PollModel keyed by poll id — the shared-instance showcase:
BRIDGE_MODEL_KEY(PollModel, OpenPoll, &OpenPoll::pollId);
BridgeHandler<PollModel, AllowShared> handler{bridge, &ui};
Actions, in build order:
CreatePoll { title, options[] }→ admin + participant link tokens.OpenPoll { pollId }(the keyed action),GetPollState {}.SubmitVotes { participantName, votes[] }— anonymous: participants have no account, and there is no participant authentication of any kind. The identity that matters is the action's own free-textparticipantName, scoped to the poll — knowing thepollIdis the capability, andCreatePollResult::participantTokenis verified by nothing (design decision 1 above, andPollModel's own "What is actually gated, stated exactly" doc comment).UpdateVotes,AddComment.FinalizePoll { optionId }— admin-token-gated state transition; the poll becomes read-only.UndoLastVoteChange— user-facing undo, redesigned per review: it must be principal-scoped ("undo my last change") and implemented as a compensating action, notSessionLog::undoLast()— which (a) pops the newest entry regardless of principal (A's undo would kill B's vote), and (b) returns a fresh detached holder that no API can install into the live server registry, so replay-undo cannot mutate a shared instance at all. Write the interleaving test first (A votes, B votes, A undoes → assert whose vote died) — its outcome is the rung's headline design record.GetEventsSince { lastEventId }— this rung's framework-level deliverable: the Zulip-pattern generic polling action (see below). Event storage, resolved (design decision 4 above): shared instances are destroyed immediately at refcount zero, so an in-instance event list dies the moment all tabs briefly close (a link shared in chat produces exactly this) — solved by persisting events to a genuinepoll_eventsSQLite table keyed by a table-wide monotonic autoincrement sequence id, not an epoch token: a reborn instance reads the same durable table and continues the same sequence, so a client holdinglastEventId = 42simply gets every real event after 42, rebirth or not. Test: attach N, mutate, detach all (verify destruction viainstances()), attach again, poll with the pre-death cursor.
Persistence: SQLite tables mirroring Rallly's Prisma models, plus the event log table above.
- Shared instances end-to-end: N clients (desktop + several WASM tabs)
attach to one server-side
PollModelinstance; refcounted lifetime when tabs close;handler.instances()for an organizer dashboard. - Anonymous principals: no framework identity at all —
Context::tokencarries the poll's admin-or-participant secret, hand-verified byPollModel::execute()itself against the poll row's own columns (design decision 1 above; there is no frameworkIAuthorizerfor bare shared secrets, so this rung does not add one). - Event polling — the pattern the rest of the ladder reuses. morph has no
server push and in-process-only subscriptions, so remote clients must ask.
Implement the Zulip events-system pattern
in miniature: every mutation appends to a per-poll event list (sequence id
- payload); clients poll
GetEventsSinceon a timer and apply increments; a stale client falls back toGetPollState. Zulip proves an entire chat product ships on exactly this; here it debuts at toy scale.
- payload); clients poll
- The framework journal, as audit only.
FileActionLogis installed process-wide (app/app.hpp) and every mutating action isLoggable, which is the whole of what the journal does here. The rung's user-facing vote-change history and undo are not built on it:SessionLog::undoLast()is principal-blind and returns a detached holder no API can install into a live shared instance, soUndoLastVoteChangeis a compensating action over this rung's owndb::VoteHistoryRecordtable instead — design decision 3 above, which resolved this before implementation started. The original "journal as user feature: vote-change history and undo" framing was the ladder's pre-implementation guess and is what design decision 3 overturned.
- WASM + shared handlers: the synchronous shared/keyed attach path
(
registerModelShared/attachModel) nests an event loop and aborts the page on the WASM main thread, so a WASM tab's very firstOpenPollmust not go through it. This is no longer a framework prerequisite:registerModelSharedAsync/attachModelAsyncship the non-blocking pair (see § Framework prerequisites above). Still worth running the "several WASM tabs" demo literally — the async path's behaviour in a browser has never been observed, only its compilation. - The polling helper must own a client-side timeout: an unwrapped poll
call against a hung server hangs its completion forever, which is what
Bridge::setExecuteDeadlinenow exists to bound. A rate-limited server no longer drops frames silently — it answers them witherr "rate limited"(morph#225) — so the limiter is no longer the case that motivates the timeout; a genuinely unresponsive server is. Done: this rung's harness does run withmessagesPerSecondconfigured ON (a polling app is the abuse case the limiter exists for), andtests/test_shared_instance_lifecycle.cpp's "The real rate limiter refuses an over-budget call without hanging it" proves the answered property end to end — the over-budget call settles on its own, with the deadline no longer being what saves it. - Poll-interval latency: two voters editing simultaneously see each other only on the next tick — measure and document acceptable intervals.
subscribe<R>fan-out is in-process only: verify the documented limit that two remote clients do not see each other's results without polling, and showGetEventsSinceclosing the gap. This rung is also the first test anywhere ofAllowSharedover the real WebSocket transport — the framework itself gains coverage here.- Poisoned-instance attach: opening a stale/mistyped poll link exercises the documented shared-instance failure modes (half-hydrated instance, eviction only on next attach, the failing handler not self-healing); also race two attaches against a failing first hydration.
- Duplicate
SubmitVoteson retry must not double-count: the strand serializes but does not dedup — participant-token + option uniqueness is a model invariant, tested under retry. - A vote in flight (or queued offline) when
FinalizePolllands must dead-letter with a user-visible outcome, not vanish. - Timezone display of candidate dates (
morph::timeis UTC-only; per-participant local rendering is GUI logic) — a good dual-mode + WASM-parity presenter test. - Shared-instance churn soak (framework-grade, promoted to
tests/soak/): threads racing register-or-attach / deregister / closeConnection / execute on one key under TSan — never two live instances for a key, attach counts never leak, every completion resolves.
- Live demo: one organizer + three participant clients on the remote
backend, votes converging via polling; finalize locks the poll everywhere.
Not satisfied. This rung ships no native desktop entry point
(
examples/polls/gui/main.cppdoes not exist — no task in its plan wrote one), and its only GUI binary,gui_wasm/main_wasm.cpp, has never been compiled for want of an Emscripten toolchain here. Nothing in this rung has therefore been run as an application against a real server. What is verified is every layer beneath that:tests/test_app.cppdrives the remote backend end to end,tests/test_poll_qml_bridges.cppdrives the whole QML-facing adapter including one realEventPollertick, andtests/test_shared_instance_lifecycle.cppcovers multi-handler convergence on one shared poll. Writing the desktop entry point and running the demo is named follow-up work, not a claim made here. - Principal-scoped undo restores the caller's previous vote via a
compensating action, verified by the two-principal interleaving test --
"principal-scoped" here means keyed on
(pollId, participantName)per design decision 1, not a framework-authenticated identity; theSessionLog::undoLastlimitation is documented in the rung's design record. Confirmed (Task 8): the interleaving test (A votes, B votes, A undoes) passes against a real SQLite-backedPollModel-- A's undo restores only A's prior (no-vote) state viaUndoLastVoteChange, and B's vote survives completely untouched, the exact outcomeSessionLog::undoLast()(principal-blind, pops the newest entry regardless of who made it) could never have produced. - Event log survives full detach/reattach (instance rebirth) and a stale
cursor triggers a clean full resync, verified by test.
Confirmed (Task 9): a
BackendRig-driven test attaches twoAllowSharedPollModelhandlers to the same poll (instances()shows one live key), drops every handler naming that poll, and confirms via a fresh handler's owninstances()that the shared instance is genuinely gone (empty directory, not just "no crash"). A brand-new handler then reattaches viaOpenPolland callsGetEventsSincewith the pre-death cursor: it gets exactly the events written after that cursor, including ones recorded before the instance died -- confirmed independently against the real on-disk SQLite file (sqlite3inspection ofpoll_events), not just the in-memory assertions. No epoch token was needed, exactly as design decision 4 above predicts. - The event-polling helper (with its client-side timeout) is factored so
kanbancan lift it. Confirmed (Task 15):morph::ladder::gui::EventPoller<EventT, EventIdT>lives inexamples/common/gui/event_poller.hpp, not in this rung — it names nopolls::type, taking its event and cursor types as template parameters and its backend reach as a caller-suppliedDispatchclosure, which is what lets kanban wire its own feed without re-deriving the retry-vs-fatal decision tree. Its behaviour is covered byexamples/common/testkit/test_event_poller.cppagainst a synthetic dispatch, independently ofpollsentirely;PollBridge::startPollingis merely its first consumer.
Task 16 built the GUI shell: gui_lib/poll_schemas.hpp (the
{actionType: schema} document), gui_lib/poll_forms_controller.{hpp,cpp}
(the one BridgeHandler<PollModel, AllowShared> every already-open-poll
action shares), gui_lib/poll_qml_bridges.{hpp,cpp} (PollBridge, the one
QML-facing adapter, wrapping both PollFormsController and PollPresenter),
and gui/qml/{Main,CreatePollView,VoteView}.qml. Three of PollModel's nine
actions are genuinely schema-driven (AddComment, FinalizePoll,
UndoLastVoteChange — all scalar-field DTOs, rendered by the shipped
MorphForms DynamicForm); the rest are dedicated PollBridge invokables,
for the reasons below.
All three schema-driven forms bind controller: page.pollBridge and carry
no submit control of their own: each action declares
static constexpr bool explicitSubmit = true, so schemaJson<A>() stamps the
top-level x-submitMode: "explicit" key and the renderer supplies its own
Submit button, enabled only while the form is ready
(docs/spec/forms/forms.md, "Explicit submit mode"). That matters because all
three mutate and FinalizePoll is irreversible — the renderer's default is to
fire on validity, which against a live controller means one dispatch per
keystroke. This rung is the ladder's first adopter of that mode; the one
consequence is that PollBridge::submitIfValid is now called only from the
renderer's own DynamicForm.qml, which QmlSurfaceAudit does not scan, so
tests/test_poll_qml_bridges.cpp records a single allowUnbound exemption
saying exactly that.
Observed, not inferred: tests/test_gui_qml_smoke.cpp's "VoteView's three
schema-driven forms render the shipped renderer's own Submit button" loads
VoteView with the real schema document and counts three submitButton
objects in the resulting item tree — with pollBridge still null, so what it
counts is what the renderer built. Dropping explicitSubmit from any one
action turns that into 2 == 3.
No gui/main.cpp, still. Task 16's brief scoped the desktop client's
entry point out (gui/*.cpp is absent from its file list), no later task in
this rung's plan added one, and the branch's final whole-branch review chose
to name the gap rather than close it. Wiring ladder_polls_gui together, and
with it the live end-to-end organizer-plus-participants demo the Definition
of Done above asks for, is follow-up work. The one entry point that does
exist is gui_wasm/main_wasm.cpp (Task 18), the browser client — which
cannot create polls (nativeClient: false) and has never been compiled.
Today ladder_polls_qml/ladder_polls_gui_lib build and
are proven by the offscreen engine-load smoke test
(tests/test_gui_qml_smoke.cpp) and the adapter-layer suite
(tests/test_poll_qml_bridges.cpp), including one real end-to-end
EventPoller tick (PollBridge's EventPoller applies a live event and refreshes state, end to end) — but nothing here has yet been run as an
actual desktop application against a real server.
Known gaps:
DynamicForm's array control handles arrays of strings only. The control itself ships (src/qt/forms/qml/DynamicForm.qml: a comma-separated-with-validation entry that encodes to a genuine JSON array literal,"a, b"→["a","b"]), and it is deliberately scoped to array-of-string — a field whoseitemsare objects still gets that same control, but every entry is encoded as a JSON string, which is not a payload such a field can accept. That narrower statement, not "DynamicFormhas no control for a JSONarrayfield", is what actually blocks this rung, and it is the wordinggui_lib/poll_schemas.hppalready carries.CreatePoll::optionsisstd::vector<CreatePollOption>, soCreatePollis excluded frompoll_schemas.hpp's document entirely and driven instead bygui/qml/CreatePollView.qml's own hand-written option-label list editor (add/remove rows), submitted throughPollBridge::createPoll(title, optionLabels)— the same shape rung 2'sBulkEditworkaround established. The same gap blocksSubmitVotes/UpdateVotes, whose one required field beyondparticipantNameisstd::vector<OneVote>. Both are excluded from the schema document too and driven bygui/qml/VoteView.qml's hand-rolled per-option Yes/If-need-be/No radio picker, viaPollBridge::submitVotes/updateVotes.OpenPollbypassesexecuteJson, and the gap that forced it is now fixed. The finding this rung surfaced was real:ActionExecuteRegistry:: registerAction<Model, Action>used to close its stored executor over the plainBridgeHandler<Model>overload ofexecute<Action>()regardless of the real handler'sSharingargument, sokSharedresolvedfalseat that call site no matter what, and dispatching a payload-keyed action throughexecuteJsonon anAllowSharedhandler silently never attached. It was fixed framework-side (morph#68):registerActionnow builds one executor perSharingpolicy from the same generic-lambda template, keyed by(modelId, actionId, typeid(Sharing)), andexecuteJsondispatches through the handler's own real policy —ActionExecuteRegistryand the out-of-lineregisterActiondefinition, both ininclude/morph/core/bridge.hpp. What remains is this rung's own unmigrated state, not a framework gap.PollFormsController::openPoll(pollId)still calls the templatedexecute<OpenPoll>()directly, andsubmitIfValidstill refusesOpenPollunconditionally via itskSchemaActionsallow-list — routing it through the now-correct generic path is work nobody has done, not work anybody is blocked on. Both that class's doc comment andpoll_schemas.hpp's already say so.PollFormsControllercannot be a verbatim copy ofbookmarks::gui::BookmarkFormsController's per-model-handler shape. Every one of bookmarks' three models is plain (NoSharing), so which handler object serves a given call never matters there.PollModelisAllowSharedand keyed: anAllowSharedhandler starts unattached and only joins the poll's shared instance the first time a payload-keyed action dispatches through that specific handler object — every other action on the same poll must reuse that exact handler.PollFormsControllertherefore owns exactly oneBridgeHandler<PollModel, AllowShared>, shared byopenPoll/getPollState/submitVotes/updateVotes/getEventsSinceand the three schema-driven actions alike, rather than one handler per concern. See that class's own doc comment for the full reasoning, andtests/test_poll_qml_bridges.cpp's "threads openPoll's attach through every later action on the same poll" case for the regression proof.- The event-driven results display resyncs on every applied event rather
than applying a true increment.
PollEvent{id, kind, summary}carries no vote-tally delta — only a human-readable summary — soPollBridge::onEventAppliedrelays it toeventReceived(for a live activity log) and separately schedules a debouncedrefresh()(GetPollState) to update the actual tallies. This is simple and correct but is one full state refetch per tick that had at least one event, not the increment-application the Zulip pattern'sREADME-level description suggests — acceptable at this rung's toy scale, worth reconsidering if a later rung's event volume makes it not. - The
CreatePollscreen is native-client-only by gate, not by absence — and the gate is a product decision, not a technical one.gui/qml/Main.qml'snativeClientproperty (defaulttrue) hides — not merely disables — the one button that reachesCreatePollView.qml.gui_wasm/main_wasm.cpp(Task 18) is what flips it, passingnativeClient: falseas an initial property. Design decision 6 above is the justification, and it is now purely the Rallly anchor-UX split: the framework hazard that entry used to cite (assignHandlerPrimary's promote step having no async path) is both fixed and inapplicable toCreatePoll, which is not result-keyed and does not dispatch on a shared handler. The consequence, stated plainly: the browser client cannot create a poll at all. A WASM participant either follows a?poll=<id>link or pastes a poll id on the landing screen; some organizer on some other client had to create it, and today no such client exists (see the next bullet). - No native desktop entry point exists, so nothing here has been run as an
application. There is no
examples/polls/gui/main.cpp; no task in this rung's plan wrote one, and this fix round deliberately did not add one either.gui_wasm/main_wasm.cppis the only GUI client binary this rung ships, and it has never been compiled (no Emscripten toolchain here — theladder-wasmCI job is a compile gate). Writinggui/main.cppand running the organizer-plus-participants demo is named follow-up work. Bridge::setExecuteDeadlineused to be unusable from a browser tab, and the fix is CI-compile-verified only.EventPoller's constructor calls it unconditionally, and it lazily builds aTimeoutScheduler, which spawned astd::thread— impossible in thewasm_singlethreadQt build these clients target.include/morph/core/timeout_scheduler.hppnow selects a browser-timer (emscripten_async_call) build of itself under__EMSCRIPTEN__ && !__EMSCRIPTEN_PTHREADS__, so deadlines still fire, on the main thread. Neither the original hazard nor the fix has been observed on a real Emscripten build; see that header's@filecomment anddocs/spec/core/completion.md.- No admin-token persistence.
PollBridge::setAdminTokeninstalls the token as the sharedBridge's default session for the remainder of the process; nothing writes it to disk or a keychain. Reopening the app (or the organizer coming back later) needs the admin token pasted in again —CreatePollView.qmlshows it once, selectable, and says so. - The offscreen QML smoke test proves loading, not behavior — same scope
note as rung 2's own smoke test (
tests/test_gui_qml_smoke.cpp's own header comment). The behavioral half istests/test_poll_qml_bridges.cppplustests/test_poll_presenter.cpp.