Skip to content

Server push (spec 8): actions declare what they change, the server tells every GUI - #990

Merged
Yaraslaut merged 69 commits into
masterfrom
docs/server-push-spec
Oct 19, 2026
Merged

Yaraslaut merged 69 commits into
masterfrom
docs/server-push-spec

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Implements spec 8 in full. Actions declare what they change, and the server tells every GUI that shows that data. Documents now declare only what a screen shows and what an edit sends; nothing lists what to refresh after an action. An agent's tool calls run through the same path, so an open GUI updates when an agent changes something.

  • Design: docs/superpowers/specs/2026-10-16-server-push-design.md.
  • Plan: docs/superpowers/plans/2026-10-18-server-push.md, 30 tasks in 9 parts, all done.

What you write

From examples/bookmarks. The full guide is docs/KEEPING-SCREENS-CURRENT.md.

1. Records, once per result type, beside the DTO:

BRIDGE_REGISTER_RECORD(bookmarks::BookmarkSummary, "Bookmarks_Bookmark", ::morph::record::privateKeys)
BRIDGE_REGISTER_RECORD(bookmarks::BookmarkView, "Bookmarks_BookmarkDetail", ::morph::record::privateKeys)
BRIDGE_REGISTER_RECORD(bookmarks::TagSummary, "Bookmarks_Tag", ::morph::record::privateKeys)

2. Effects, beside each action that changes something:

// Renaming a tag renames it on every bookmark that carries it.
BRIDGE_REGISTER_ACTION(bookmarks::TagModel, bookmarks::RenameTag, "RenameTag")
BRIDGE_REGISTER_EFFECTS(bookmarks::RenameTag, ::morph::effects::changes<bookmarks::TagSummary>,
                        ::morph::effects::changes<bookmarks::BookmarkSummary>,
                        ::morph::effects::changes<bookmarks::BookmarkView>)

// The reply is the edited bookmark's full view, so an open detail view is patched in place.
BRIDGE_REGISTER_ACTION(bookmarks::BookmarkModel, bookmarks::EditBookmark, "EditBookmark")
BRIDGE_REGISTER_EFFECTS(bookmarks::EditBookmark, ::morph::effects::updates<bookmarks::BookmarkView>,
                        ::morph::effects::changes<bookmarks::BookmarkSummary>,
                        ::morph::effects::changes<bookmarks::TagSummary>)

// A read declares nothing; an action run as a mutation that changes nothing says so.
BRIDGE_REGISTER_EFFECTS(bookmarks::ExportBookmarks, ::morph::effects::none)

3. Screens that say only what they show and what they send:

auto const tagModel = screen.model<TagModel>("tagModel", {.instance = ui::Instance::Private});
auto const tags = screen.query<ListTags>("tags", tagModel, {});
auto const picked = ui::element("tagList");
return ui::row({
    ui::table(tags.value().at(&ListTagsResult::tags), {.key = ui::row("id"), /* columns */}).id(picked),
    ui::form<RenameTag>(tagModel, {.prefill = ui::obj({{"id", ui::call("first", {picked.selection()})}}),
                                   .fields = {{&RenameTag::id, {.hidden = true}}}}),
});

When the rename succeeds, this table and every other screen holding tags, bookmark rows or a bookmark's detail
refresh, on every connected client, and the same happens when an agent renames the tag over MCP.

What goes away from screens (bookmarks on master, then on this branch):

// master
auto const archive = screen.mutation<BulkEdit>("archive", marks, {.invalidates = {bookmarks, tags}});
auto const remove = screen.mutation<DeleteBookmark>("remove", marks,
    {.body = {{&DeleteBookmark::id, editing}}, .invalidates = {bookmarks, tags}, .onSuccess = {ui::close(edit)}});
auto const refreshed = ui::Commands{ui::refetch(bookmarks), ui::refetch(tags)};  // passed to every form's onSuccess

// this branch
auto const archive = screen.mutation<BulkEdit>("archive", marks);
auto const remove = screen.mutation<DeleteBookmark>(
    "remove", marks, {.body = {{&DeleteBookmark::id, editing}}, .onSuccess = {ui::close(edit)}});
The action… Declare
changes rows of R, and its reply holds them with their keys updates<R>: the caller's screens show the reply's rows at once; watching clients refetch the entries holding those keys
changes rows of R, but its reply does not hold them (delete, bulk edit, import) changes<R>: every entry holding R refetches
changes the state of the instance it ran on changesInstance
changes nothing, but runs as a mutation none
only reads nothing

ui::refetch(q) stays for a refresh no action can declare, and .refreshEvery for data that changes with time.

What it does

Records and effects (core)

  • BRIDGE_REGISTER_RECORD names a result type with a key (key<&T::m>, keyless, derivedFrom<…>, privateKeys).
  • BRIDGE_REGISTER_EFFECTS declares updates<R>, changes<R>, changesInstance or none. Each action's registration records where its result holds which record types.
  • Records and effects are matched and validated on the first registry read. A bad declaration fails at server construction with EffectsRegistrationError. This includes a derivedFrom source that is not a registered record, which would otherwise never refresh the record derived from it.
  • deriveChanges computes the changes an execute made. Past kMaxChangeKeys keys, a change becomes a whole-type change.

The server-to-client channel

  • An event envelope (changes, resync), which clients route before matching replies.
  • RemoteServer writes to a connection through setConnectionWriter, post and connectionDrained.
  • Events are held back above a 1 MiB backlog, and one resync follows the drain. Replies are never held.
  • Both transports count each connection's backlog; over TLS, Qt counts the encrypted bytes. Qt gains sendTimeout.

Watching and fan-out

  • watch and unwatch are core kinds. Admission is per principal and token, an instance watch also needs authorizeInstance, and a connection may watch at most 4096 topics.
  • A successful execute fans out to every watching connection, the caller included. Each watch is re-authorised when an event is sent, and privateKeys records lose their keys for any other principal.
  • A change of principal drops the connection's watches, and any dropped watch is owed a resync.
  • Changes are coalesced per connection for 50 ms or 256 keys.

The client cache

  • Every reply applies its action's effects through Session::actionSucceeded. This covers mutations and the submits of forms, collections and wizards.
    • updates R patches rows by the record's key field, at the catalog's paths, with no fetch.
    • changes R and changesInstance mark the entries that hold the record, or that were read through the instance.
    • A discarded latest reply only marks.
  • A mark made while a fetch is in flight is kept (refetchAfter): that entry fetches once more, so no change is lost.
  • Session::deliverEvent applies changes events, filtered by the event's keys, and answers resync.
  • The session counts the topics its cache holds and watches them before it fetches, through SessionOptions::watcher, built with watcherOver. Session::reconnected(), and every resync, re-send the whole watch set with replace and mark every entry stale.
  • AppSource::onConnected is a new seam. On every reconnect, the generic client re-sends ui-hello, re-watches, and forwards events.
  • The catalog lists a record derived from a changed one as changes too, so the caller's own reply marks it in process as well as remotely.

Documents

  • A query whose action declares effects is refused: a query must read.
  • invalidates, patch and refreshOn are refused: an action's effects say what it changes, once, for every client. optimistic, refreshEvery and the refetch command stay.
  • Two warnings, which never refuse a document: a mutation that declares nothing and refreshes nothing; a watch of a query that runs a mutation with effects.
  • The explorer shows each action's effects, and the records its result holds, each with its key field and paths.

The example rungs. Bookmarks, bank and lims declare records and effects beside their actions. Their screens declare no refreshes, and each rung's bundle loads without refusals or warnings.

Documentation

  • docs/KEEPING-SCREENS-CURRENT.md, a new guide for developers and coding agents: what to write, choosing an effect, what refreshes when, a checklist for a new action, every error and warning with its fix, testing, and agents over MCP.
  • GETTING-STARTED.md, ARCHITECTURE.md, the README, examples/IMPLEMENTATION.md and AGENTS.md point to it and no longer show refresh keys or say morph has no server push.

The MCP adapter

  • ui::McpAdapter speaks MCP's JSON-RPC 2.0 over an ordinary server connection, with one tool per permitted action and an evaluate dry run.
  • serveStdio drives it over MCP's stdio transport.
  • Bank's server serves it with --mcp-stdio, beside its WebSocket clients, and now also serves bank's screens through a UiService.

Changes from the plan

  • serveStdio takes the adapter's owner, a MainThreadExecutor, and pumps it while it waits. McpOptions gains callbacks, the serial owner the adapter's completions are delivered on.
  • A reply's effects are applied from the reply's value. applyChanges(ChangesBody) serves events only, because a patch needs rows, and a ChangesBody carries only keys.
  • Watch changes go out at each fetch, not once per reactive batch, because the runtime has no end-of-batch hook. Each fetch is still preceded by its watch.
  • One vocabulary. The plan's ui/2 is folded into ui/1: the refresh keys are refused there instead of in a second vocabulary, so there is no per-client choice of documents. Spec 5 §12 says a key leaves a vocabulary by being refused, never ignored.
  • The --mcp-stdio identity comes from MORPH_MCP_PRINCIPAL, because bank's demo authorizer vouches for a principal and has no token. MORPH_MCP_TOKEN is carried as the token.

Verification

Measured on Linux (Arch), clang 23, Debug, with net, Qt, the ladder and bank on, at 40740124; the later commits re-ran the full suite (all pass), Doxygen (clean), and a local branch-coverage run of include/morph/core (94.15% against the 94% floor, without the examples):

  • Full build: OK.

  • ctest: 4014 of 4016 pass. The two failures were timeouts under -j6 on a loaded disk. Both pass alone: ListBookmarks … nextCursor in 0.14 s, and App teardown survives fetch dispatches still outstanding in about 90 s. On master's own build that test takes 81 s on the same disk, so the load is the machine's, not the branch's.

  • Doxygen (WARN_AS_ERROR): no warnings.

  • GCC: every non-Qt translation unit under examples/, src/ and tests/ (378) compiles with GCC and CI's gcc-release flags. With a fix undone, the check flags the error.

  • Mutation checks: each new behaviour's tests were run with the behaviour broken and fail as they should, including:

    • the cache's patch, mark, event and resync paths;
    • watch-before-fetch;
    • the reconnect order;
    • the document checks;
    • the refresh keys' refusals;
    • the explorer rows;
    • the adapter's tool list;
    • the derived-record expansion;
    • the adapter's keyed path, and the watch table's per-session drop.

    The net backlog test fails without the hold-back. The two-client test over net fails with B's events dropped.

CI on 40740124 was green on every build and test job, Windows cl and clang-cl and the sanitizers included. The exceptions were clang-tidy-diff and the coverage gate (core's branch coverage under its floor), both fixed in the commits after it.

Not verified locally:

  • clang-tidy at CI's version (fixed from CI's logs);
  • macOS on this round;
  • MORPH_CLIENT_ONLY, beyond its guard probes in the normal build.

Known limitations

  • An updates effect cannot express a patch whose reply replaces its row under a new key. Lims's re-capture declares changes and refetches.
  • From the review of Parts 1–4, still open:
    • If Qt's TLS socket cannot be found, the connection fails open (no hold-back, no sendTimeout).
    • The unscoped ui-hello answers events: true.
    • The admission cache keeps policy changes for a live token until that token stops verifying.
    • A keyed row that lacks its key member is skipped in deriveChanges.

🤖 Generated with Claude Code

https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ

Yaraslaut and others added 27 commits October 18, 2026 09:37
…s the GUI (spec 8)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… with its implementation plan

The examples the spec cites are bookmarks, bank and lims; ledger's derived
report becomes bank's SpendingReport. Matching runs on the first registry
read, since a release build's latch closes only on request. The explorer
shows effects and records. The MCP adapter's shape is fixed. Delivery is
one PR in nine ordered parts, planned in docs/superpowers/plans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
std::ranges::iota is missing from Apple's libc++ as it is from
Emscripten's, so the table suite did not compile on macOS. The library
already uses std::iota for the same reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ypes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r one id is a conflict

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ffect validation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… from a slow reader

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ect handler

SocketServer hands RemoteServer a ConnectionWriter for each accepted
connection. Every frame handed to it, replies and events alike, is counted
on the connection's atomic backlog by the size of its encoded envelope, and
released on the loop once its last chunk is written or it is dropped; a
release that takes the backlog below kEventBacklogLow calls
connectionDrained. SocketBackend routes event envelopes before reply
matching and posts them to the setEventHandler executor, and runs the
setConnectHandler handler after each connect, the first included.

RemoteServer keeps an owed resync when a connection's writer is replaced,
and catches a throwing backlog() read like a throwing write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nts in their own header

SocketServer hands RemoteServer a connection's writer only once the 101
response is queued, so an event posted to a connection still in its
handshake is dropped instead of reaching the peer ahead of the 101.
ConnectionWriter and the backlog bounds move to core/connection_writer.hpp,
so the shared connection code no longer compiles RemoteServer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
QtWebSocketServer hands RemoteServer a ConnectionWriter for each connection
as it is accepted (the upgrade is already complete by then). Every frame
written to a connection, replies and events alike, counts toward its atomic
backlog by the size of its encoded envelope; a FIFO of each frame's end
position in the socket's written-byte count releases it from bytesWritten,
and a release that crosses below kEventBacklogLow calls connectionDrained.
QtWebSocketServerConfig::sendTimeout (default 30 s, as net) aborts a peer
whose oldest unwritten frame has outlived it.

QtWebSocketBackend routes event envelopes before reply matching and posts
them to the setEventHandler executor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Over wss:// QWebSocket::bytesToWrite() counts only plaintext not yet
encrypted, which Qt encrypts eagerly, so the server released a frame long
before its ciphertext was sent: a stalled TLS reader got every event and
never tripped sendTimeout. A frame's release position is now measured when
it is handed over -- bytes written so far plus everything the socket stack
holds, the QSslSocket's encryptedBytesToWrite() included -- in a small
detail::OutboundLedger, and "nothing left to write" means nothing in either
buffer. The QSslSocket is found among the QWebSocketServer's descendants by
the connection's endpoints.

Tests cover a stalled reader held back and resynced over ws and wss, the
send timeout over wss, and the ledger's release by position; the
cleared-handler check no longer passes vacuously.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…every transport

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tionFacts

IEnvelopeService gains a non-pure three-parameter handle taking the
connection's ConnectionFacts, whose default forwards to the pure
two-parameter one. RemoteServer passes the facts of the envelope's cid,
kept beside the connection scopes on the server strand and dropped by
closeConnection; a message with no open connection gets throwaway facts.
eventsNegotiated(cid) reads the flag, server strand only.

HelloRequest and HelloReply gain an additive `events`. UiService accepts
an offer on a hello it answers ok and sets facts.events; the generic
client's ui-hello (clientHello) offers it.

The in-repo two-parameter services add `using IEnvelopeService::handle;`:
GCC's -Woverloaded-virtual at level 2 flags the hidden overload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Yaraslaut and others added 15 commits October 19, 2026 00:03
The reply-effect and change-event functions are split into one method per
kind of change, around a Marks collector and helpers for a record's rows
at its paths, which brings each under the complexity limit. markStale is
static. Qt's queued callable is owned by Qt, which the analyzer cannot
see. The test leftovers reserve their vectors and drop an unused using.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
…embers

Its member fold is empty then, so GCC reports the helper lambda as set but
not used, which -Werror makes an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
A query whose action declares effects is refused at load: a query must
read. An evaluate query is exempt, since an evaluation runs no handler.
Three warnings, collected in Document::warnings and logged by the bundle
registry and the screen registry, never refuse a document: a refresh
declaration (invalidates, patch, refreshOn) the mutation's effects already
cover; a mutation whose action declares nothing and that refreshes
nothing; and a watch of a query, from any source, that runs a mutation
with effects, which can make two clients refetch each other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
The drain finds nothing in flight as soon as the server has answered
what it has read, which under UBSan was before it had read all 128
requests: the event then went out ahead of the replies. The test now
waits until the model has run every execute, then for the drain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
…turns

Its Facts gain an effects row (each effect, or reads when the action
declares nothing) and, when the result holds records, a records row with
each record's key field and paths, so why a screen did or did not update
can be read without the source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
One RemoteServer and SocketServer, two SocketBackend clients, each with a
UI session wired to the server's events and watches, mounting one screen
whose query declares no refresh. Client A renames a row; A updates from
its reply's effects and B from the server's event. With B's events
dropped, B's check fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
…esh declarations

Bookmarks_Bookmark, Bookmarks_BookmarkDetail and Bookmarks_Tag are
records with private keys, and every action declares what it changes.
The screens lose their invalidates and their refetch-only onSuccess
commands. A shared testkit helper loads every document of a served
bundle against its catalog facts, and each rung asserts it loads
without refusals or warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
…eclarations

Accounts, transactions, cards, payees, loans, payments, budgets and
notifications are records with private keys, declared identically for
both model sets; Bank_SpendingReport is derived from transactions. The
screens lose their invalidates and refetch-only onSuccess commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
…eclarations

Results, samples, verifications, conflicts and analysis versions are
lab-wide records. Lifecycle transitions update their sample, instance
actions change their instance, and OpenSample changes nothing. The
screens lose their invalidates, refreshOn, patch and refetch-only
onSuccess commands; a re-capture, which replaces its row under a new
key, now refetches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
McpAdapter speaks MCP's JSON-RPC 2.0 over one server connection: one tool
per action the session may run, with the action's schema, a keyed
model's tool taking its instance, and an evaluate tool for a dry run. A
call is an ordinary execute, so every check applies and every watching
GUI is told; a refused call is a tool result with isError. serveStdio
drives it over MCP's stdio transport, pumping the adapter's owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
A record derived from one an action updates or changes changes as a
whole too. The server expands derivations before an event leaves, but
the caller's own reply applied only the declared effects, so in process,
where no event comes back, a reply could not mark entries holding a
derived record (read from the code; no rung screen shows one today).
CatalogFacts::action now lists each derived record as changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
… documents and client

The cache's row helpers take a record's paths and key field, so every
optional is read where it is checked; sendWatch is const. The document
checks move a query's into checkQuery, under the complexity limit. The
shell registers the source's listeners in listen(). onConnected takes
its listener by reference. The analyzer's leak report on Qt's queued
callable is suppressed where its path starts, in our code. The
effects link probe is listed as deliberately not profiled, beside the
other link canaries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
…et server

The server installs a UiService over bank's screens, as the other rung
servers do, and with --mcp-stdio runs an McpAdapter on stdin and stdout
on a thread of its own: each permitted bank action is a tool, and a call
reaches every watching GUI. Stdout then carries only JSON-RPC, so the
status lines go to stderr; the server stops when stdin ends. The
agent's identity is MORPH_MCP_PRINCIPAL, with MORPH_MCP_TOKEN as its
token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
…ll load

Under ui/2 a document refuses the three refresh keys, since actions
declare what they change; optimistic keeps its {query, at?, key, with}
shape as a definition of its own, and refreshEvery and refetch stay. A
ui/1 document loads exactly as before. The authoring layer emits ui/2
and no longer offers invalidates, patch or refreshOn; the generic client
speaks both vocabularies. A bundle needs the vocabularies its documents
declare. The goldens are regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
@Yaraslaut Yaraslaut changed the title Server push (spec 8): actions declare what they change, the server tells the GUI Server push (spec 8): actions declare what they change, the server tells every GUI Oct 19, 2026
Yaraslaut and others added 3 commits October 19, 2026 07:08
…perator[]

- BRIDGE_REGISTER_RECORD and BRIDGE_REGISTER_EFFECTS suppress the unnamed
  namespace in a header once, at the macro, as BRIDGE_REGISTER_ACTION does.
- McpAdapter reads members through findMember and builds replies from
  object initializers. That also makes a parse error's id null and
  evaluate's required list ["action","body"], which brace-initialising
  glaze's generic had turned into one-element arrays.
- The adapter's destructor does not throw; handle() takes its message by
  const reference; serveStdio names its streams input and output.
- Tests cover the keyed-model path (a tool's instance is the shared
  instance under that key) and each JSON-RPC fault.
- Bank's server reads its arguments through a span and its environment
  before the agent thread starts; smaller fixes in the explorer, the
  document checks and the test kit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
Such a source never changes, so the record derived from it would never be
refreshed, and nothing said so. Resolution now refuses it with the record's
name, beside the cycle check. The specs list it among the refusals.

Tests drive each branch of deriving changes and of resolution: arrays and
wrappers before a path ends, merged keys and whole types, instances kept
once, a record derived along two routes, updates of an unknown or keyless
record, pathWithin, sameRegistration and a record held at two paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
… allowlist hints

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Reconnect ordering, oversized-event handling, and the bank screens dependency leave unresolved correctness and build failures.

3 open issues
What changed in this PR

Adds action-driven server push so GUI caches refresh when another client or agent changes data, replacing per-screen refresh declarations.

Changes:

  • Adds event delivery, connection backlog tracking, watches, and reconnect integration.
  • Migrates authored screens to ui/2 with record and effect declarations.
  • Adds bank MCP serving, regression coverage, and supporting documentation.
File Description
tests/​ui/​golden/​results.json Updates vocabulary golden.
tests/​ui/​golden/​palette.json Updates vocabulary golden.
tests/​ui/​golden/​declarations.json Removes obsolete refresh declarations.
tests/​ui/​golden/​commands.json Updates vocabulary golden.
tests/​ui/​corpus/​documents.json Covers ui/2 validation.
tests/​ui_interpreter_support.hpp Adds effect and watch fixtures.
tests/​test_ui_explorer.cpp Tests effect display.
tests/​test_ui_author.cpp Migrates authoring tests.
tests/​test_remote_envelope_service.cpp Tests connection facts and compatibility.
tests/​test_push_watch_table.cpp Tests watch isolation and limits.
tests/​table/​test_table_view_change.cpp Uses portable std::iota.
tests/​table/​test_table_sort.cpp Uses portable std::iota.
tests/​table/​test_table_filter.cpp Uses portable std::iota.
tests/​push_records_late.hpp Defines late-registration fixtures.
tests/​push_records_late.cpp Registers late fixture records.
tests/​net/​test_socket_backend.cpp Exposes service overloads.
tests/​net/​CMakeLists.txt Registers network push tests.
tests/​compile_checks/​effects_unseen_shared.hpp Defines cross-unit fixtures.
tests/​compile_checks/​effects_unseen_b.cpp Reads effects without registration visibility.
tests/​compile_checks/​effects_unseen_a.cpp Checks shared effect registration.
tests/​CMakeLists.txt Registers push tests and probes.
src/​qt/​qt_websocket_backend.cpp Routes events before replies.
scripts/​check_coverage_objects.sh Excludes the link probe.
scripts/​branch_partial_allowlist.json Updates coverage locations.
include/​morph/​ui/​service.hpp Negotiates events and bundle vocabularies.
include/​morph/​ui/​service_client.hpp Adds remote watcher construction.
include/​morph/​ui/​frontend.hpp Adds connection listeners.
include/​morph/​ui/​explorer.hpp Displays effects and records.
include/​morph/​ui/​bundle.hpp Adds event negotiation fields.
include/​morph/​qt/​qt_websocket_server.hpp Declares backlog and timeout support.
include/​morph/​qt/​qt_websocket_backend.hpp Declares event handlers.
include/​morph/​qt/​detail/​outbound_ledger.hpp Tracks unwritten frames.
include/​morph/​net/​socket_server.hpp Installs connection writers.
include/​morph/​net/​socket_backend.hpp Adds connect and event callbacks.
include/​morph/​net/​detail/​ws_connection.hpp Accounts for outbound backlog.
include/​morph/​core/​observability.hpp Adds oversized-event metric.
include/​morph/​core/​detail/​reply_router.hpp Separates events from replies.
include/​morph/​core/​detail/​change_coalescer.hpp Merges pending changes.
include/​morph/​core/​connection_writer.hpp Defines writers and backlog thresholds.
include/​morph/​core/​change_types.hpp Defines change payloads.
include/​morph/​core/​backend.hpp Adds backend event hooks.
examples/​lims/​screens/​tests/​test_lims_screens.cpp Tests effect-driven screen updates.
examples/​lims/​screens/​lims_screens.cpp Removes explicit refresh declarations.
examples/​lims/​README.md Documents effect-driven updates.
examples/​lims/​include/​lims/​models/​sample_model.hpp Declares sample action effects.
examples/​lims/​include/​lims/​models/​auth_model.hpp Declares login effects.
examples/​lims/​include/​lims/​models/​analysis_catalog_model.hpp Declares analysis effects.
examples/​lims/​include/​lims/​dto/​verification_dto.hpp Registers verification records.
examples/​lims/​include/​lims/​dto/​sample_dto.hpp Registers sample records.
examples/​lims/​include/​lims/​dto/​result_dto.hpp Registers result records.
examples/​lims/​include/​lims/​dto/​offline_dto.hpp Registers conflict records.
examples/​lims/​include/​lims/​dto/​analysis_dto.hpp Registers analysis records.
examples/​common/​testkit/​bundle_warnings.hpp Checks bundle validation warnings.
examples/​common/​app/​tests/​test_sources.cpp Tests reconnect and event forwarding.
examples/​common/​app/​client_shell.cpp Connects sessions to push notifications.
examples/​common/​app/​app_source.hpp Declares watcher and listener interfaces.
examples/​common/​app/​app_source.cpp Implements negotiation and reconnect notifications.
examples/​bookmarks/​screens/​tests/​test_bookmarks_screens.cpp Checks warning-free bundles.
examples/​bookmarks/​screens/​bookmarks_screens.cpp Removes explicit refresh declarations.
examples/​bookmarks/​README.md Documents action effects.
examples/​bookmarks/​include/​bookmarks/​models/​tag_model.hpp Declares tag effects.
examples/​bookmarks/​include/​bookmarks/​models/​bookmark_model.hpp Declares bookmark effects.
examples/​bookmarks/​include/​bookmarks/​models/​auth_model.hpp Declares login effects.
examples/​bookmarks/​include/​bookmarks/​dto/​tag_dto.hpp Registers private-key tag records.
examples/​bookmarks/​include/​bookmarks/​dto/​bookmark_dto.hpp Registers private-key bookmark records.
examples/​bank/​tests/​test_mcp_stdio.cpp Tests MCP stdio responses.
examples/​bank/​src/​server/​main.cpp Serves screens and MCP tools.
examples/​bank/​screens/​tests/​test_bank_screens.cpp Checks warning-free bundles.
examples/​bank/​screens/​bank_screens.cpp Removes explicit refresh declarations.
examples/​bank/​README.md Documents MCP and action effects.
examples/​bank/​memory/​include/​bank/​models/​transaction_model.hpp Declares in-memory transaction effects.
examples/​bank/​memory/​include/​bank/​models/​payment_model.hpp Declares in-memory payment effects.
examples/​bank/​memory/​include/​bank/​models/​payee_model.hpp Declares in-memory payee effects.
examples/​bank/​memory/​include/​bank/​models/​loan_model.hpp Declares in-memory loan effects.
examples/​bank/​memory/​include/​bank/​models/​customer_model.hpp Declares in-memory account-creation effects.
examples/​bank/​memory/​include/​bank/​models/​card_model.hpp Declares in-memory card effects.
examples/​bank/​memory/​include/​bank/​models/​auth_model.hpp Declares in-memory authentication effects.
examples/​bank/​memory/​include/​bank/​models/​account_model.hpp Declares in-memory account effects.
examples/​bank/​include/​bank/​models/​transaction_model.hpp Declares transaction effects.
examples/​bank/​include/​bank/​models/​payment_model.hpp Declares payment effects.
examples/​bank/​include/​bank/​models/​payee_model.hpp Declares payee effects.
examples/​bank/​include/​bank/​models/​notification_model.hpp Declares notification effects.
examples/​bank/​include/​bank/​models/​loan_model.hpp Declares loan effects.
examples/​bank/​include/​bank/​models/​customer_model.hpp Declares account-creation effects.
examples/​bank/​include/​bank/​models/​card_model.hpp Declares card effects.
examples/​bank/​include/​bank/​models/​budget_model.hpp Declares budget effects.
examples/​bank/​include/​bank/​models/​auth_model.hpp Declares authentication effects.
examples/​bank/​include/​bank/​models/​account_model.hpp Declares account effects.
examples/​bank/​include/​bank/​dto/​transaction_dto.hpp Registers transaction records.
examples/​bank/​include/​bank/​dto/​payment_dto.hpp Registers payment records.
examples/​bank/​include/​bank/​dto/​payee_dto.hpp Registers payee records.
examples/​bank/​include/​bank/​dto/​notification_dto.hpp Registers notification records.
examples/​bank/​include/​bank/​dto/​loan_dto.hpp Registers loan records.
examples/​bank/​include/​bank/​dto/​card_dto.hpp Registers card records.
examples/​bank/​include/​bank/​dto/​budget_dto.hpp Registers budgets and derived reports.
examples/​bank/​include/​bank/​dto/​account_dto.hpp Registers account records.
examples/​bank/​CMakeLists.txt Links screens and adds MCP tests.
docs/​superpowers/​specs/​2026-10-08-app-hosting-design.md Updates push protocol description.
docs/​superpowers/​specs/​2026-10-07-ui-document-design.md Documents ui/2 semantics.
docs/​spec/​ui/​service.md Documents negotiation and watchers.
docs/​spec/​ui/​interpreter.md Documents cache effects and resynchronization.
docs/​spec/​ui/​frontend.md Documents reconnect integration.
docs/​spec/​ui/​explorer.md Documents effect display.
docs/​spec/​ui/​author.md Documents revised authoring API.
docs/​spec/​ui/​agent.md Specifies MCP adapter behavior.
docs/​spec/​testing_strategy.md Documents bundle warning checks.
docs/​spec/​offline/​offline.md Explains replay-driven cache updates.
docs/​spec/​core/​wire.md Specifies event and watch messages.
docs/​spec/​core/​shared_instances.md Documents keyed-instance watches.
docs/​spec/​core/​observability.md Documents oversized-event metric.
CMakeLists.txt Exports new push and agent headers.

Review effort: Balanced

Comment thread examples/bank/CMakeLists.txt
Comment thread include/morph/core/detail/change_coalescer.hpp
Comment thread examples/common/app/app_source.cpp Outdated
The refresh keys restate per document what an action's effects say once for
every client, so a document that declares one is now refused, pointing at
the key, rather than a second vocabulary keeping them. ui/2 and the
per-document vocabulary needs of a bundle are gone; the generic client
speaks ui/1 and expr/1 again, and the authoring layer emits ui/1.

The redundant-refresh warning goes with the keys, and the uncovered warning
asks only whether a refetch follows. The interpreter's patch, invalidation
and refreshOn paths are removed; optimistic keeps the row replacement.

Tests of the removed behaviour are dropped; the `at` and no-list cases move
to optimistic, and the form, collection and wizard submits are checked to
apply their actions' effects. Specs 5 and 8 and the ui specs describe the
one vocabulary; the goldens change only their vocab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
@Yaraslaut
Yaraslaut marked this pull request as ready for review October 19, 2026 06:17
Yaraslaut and others added 4 commits October 19, 2026 08:20
…t to it

docs/KEEPING-SCREENS-CURRENT.md is for application developers and coding
agents: the three things to write (records, effects, screens), choosing an
effect, what an action's result holds, what refreshes on the caller, the
server and other clients, the screen rules, a checklist for every new or
changed action, every startup error, load-time refusal and warning with its
fix, testing, agents over MCP, the explorer and the limits.

GETTING-STARTED's screen example no longer uses invalidates, and it and the
README no longer say morph has no server push. ARCHITECTURE gains the
section and the new namespaces and headers; IMPLEMENTATION.md and AGENTS.md
make declaring effects a rule. The registry spec allows the effects macro in
a header, as the rungs use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
- core/remote.hpp: a coalesced `changes` event over the envelope cap is no
  longer dropped. It is sent with its records as whole-type changes, and as a
  `resync` if even that does not fit, so a watcher is never missed; test
  coalesced_event_over_the_cap_goes_as_whole_types (per Copilot).
- examples/common/app: the connect listeners run in a task of their own,
  after the bridge's reconnect handler, so a mounted query's refetch waits
  for the rebind instead of using the old connection's model id; the
  ui-hello still goes first. Test remote_source_refetches_after_the_bridge_
  rebinds replays a transport's reconnect over a populated query (per
  Copilot).
- CMake: bank's screens are built whenever the bank server is, with
  MORPH_BUILD_EXAMPLES off too; their tests need the generic app and are
  built only with it (per Copilot).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4hwcANrTbPYnqmWX4iyMZ
@Yaraslaut
Yaraslaut merged commit e707ba3 into master Oct 19, 2026
25 of 27 checks passed
@Yaraslaut
Yaraslaut deleted the docs/server-push-spec branch October 19, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants