Skip to content

feat: add personalized state payload broadcasts - #8

Merged
cardmagic merged 3 commits into
mainfrom
agent/state-payload-broadcasts
Aug 9, 2026
Merged

feat: add personalized state payload broadcasts#8
cardmagic merged 3 commits into
mainfrom
agent/state-payload-broadcasts

Conversation

@cardmagic

Copy link
Copy Markdown
Owner

Adds the first of the two capabilities requested for low-latency reactive updates: a
personalized state payload broadcast that collapses several component refreshes into one
message on the stream the page already has open.

Scope note up front: batched component refreshes are not in this PR. See "What is not here"
below. This PR satisfies acceptance criteria 2, 3, 4, 5, and 6; criterion 1 and the batching
half of 6 remain open.

The problem, measured against the existing protocol

Today one actor mutation that changes three observables produces three Action Cable
invalidations, three <solid-objects-refresh> elements, and three HTTP round trips:

commit -> 3 Action Cable messages -> 3 GET /solid_objects/components -> 3 ERB renders -> 3 morphs

ActorChannel#receive_broadcast fans out per invalidation, and each refresh element issues its
own fetch. That is the mtg-playmat player / player-controls / library-search case.

What this adds

class PlaymatRoom < SolidObjects::Actor
  actor_type "playmat_room"

  broadcast_payload :playmat_state do |room, authorization_context|
    {
      "turn" => room.turn,
      "hand" => room.hands.fetch(authorization_context.session_id, [])
    }
  end
end
<%= solid_object room, payloads: :playmat_state do |actor| %>
document.addEventListener("solid-objects:payload", (event) => {
  const { name, revision, payload } = event.detail
  if (name === "playmat_state") renderPlaymat(payload)
})

After:

commit -> 1 Action Cable message -> 0 HTTP requests -> 1 render

Design decisions

The payload is computed per subscriber, never broadcast pre-rendered. The Action Cable
broadcast still carries only invalidation metadata. Each subscribed channel computes its own
payload with its own connection as the authorization context. This is what makes session
isolation structural rather than a filtering step: there is no shared rendered payload that
could leak.

Transport is a Turbo Stream element, not a second socket. <solid-objects-payload> mirrors
the existing <solid-objects-refresh> morph element, so payloads ride the existing
turbo-cable-stream-source subscription. No new channel, no second WebSocket system, no
frontend framework.

Framework-neutral client. 68 lines, one custom element, dispatches a DOM CustomEvent. The
application owns rendering.

Authorization and isolation

The payload name is signed into the stream token, so a browser cannot request a payload the
server did not offer, and the channel rejects unknown names. Each payload passes the same
authorize_query boundary components use, called with the payload name and the subscriber's
Cable connection; a subscriber that fails is skipped rather than served a partial payload.
Payload blocks read committed state through the same snapshot components use and cannot write
application records. Return values must be a JSON object or array.

Revision fencing

Every payload carries instance_id and the monotonic state_revision. The channel tracks the
last revision it delivered and skips anything not newer; the browser does the same per scope and
name. A reconnecting client receives the current payload on subscribe.

Compatibility

  • No database change. No migration, no initializer regeneration.
  • Signed token format: payloads is a new optional key in the stream token. Existing tokens
    verify unchanged, and tokens are short-lived page-scoped values, so a deploy needs no
    coordination.
  • Existing applications are untouched. An actor with no broadcast_payload and a scope with
    no payloads: behave exactly as before; the payload JS is only included when the option is used.

What is not here

Batched component refreshes. The design I would implement: keep ERB rendering and return
HTML frames inside a JSON envelope at a new batch endpoint. HTML-only would force the client
to parse an undocumented document; JSON-only would mean a second renderer and would abandon
Turbo morph. A JSON envelope of {target, revision, html} descriptors gives a documented,
machine-readable contract while ComponentRenderer still produces the frames. The harder part
is server-side coalescing: broadcasts are one row per observable and arrive as separate Action
Cable messages, so emitting exactly one refresh event per actor/group/revision means grouping in
the broadcast executor rather than the channel. That is a delivery-path change worth reviewing on
its own.

Also outstanding: JS/browser-level tests (this repo has no JS test harness yet), a benchmark
comparing request counts and end-to-end latency, and concurrent-mutation and
disconnect/reconnect integration tests.

Validation

bundle exec rake

254 runs, 1005 assertions, 0 failures. Standard Ruby, RuboCop, RBS, Steep, and Brakeman clean.

Nine new tests cover personalization across sessions, that one session's private state never
appears in another's payload, identity and monotonic revisions, authorization refusal, unknown
payload names, non-object return values, stream-token signing and tampering, the rendered
element, and that actors without payload broadcasts are unaffected.

Actors can declare broadcast_payload to send one personalized JSON state payload over the actor stream a page already has open, instead of one HTTP component refresh per changed component. The block runs once per subscriber with that subscriber's authorization context, so private actor state never crosses sessions. Payloads carry actor identity and the monotonic state revision, and the channel and browser both drop stale revisions. ERB component refreshes remain the default and are unchanged.
@greptile-apps

greptile-apps Bot commented Aug 9, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds subscriber-personalized state payloads over existing actor streams. The latest change completes the payload-only mutation path by persisting a revision-only invalidation that triggers payload recomputation without exposing observable or private state.

  • Declares and signs named payload subscriptions.
  • Computes and authorizes payloads independently for each Cable connection.
  • Adds server- and browser-side revision fencing.
  • Enqueues a metadata-only invalidation when state changes without an observable change.
  • Adds integration coverage for personalization, authorization, token integrity, payload rendering, and payload-only mutations.

Confidence Score: 5/5

The PR appears safe to merge.

The previously reported payload-only mutation failure is fixed: a committed state change without observable changes now creates a revision-only broadcast, and the channel uses that invalidation to recompute subscriber-specific payloads without forwarding private state.

Important Files Changed

Filename Overview
lib/solid_objects/executor.rb Detects net state changes and persists a revision-only broadcast when payload state changes without a declared observable.
lib/solid_objects/actor_channel.rb Validates signed payload names, suppresses revision-only observable output, and computes fenced personalized payloads for each subscriber.
lib/solid_objects/payload_broadcast.rb Authorizes and serializes a named payload using the subscriber’s connection context.
app/assets/javascripts/solid_objects/state_payload.js Parses payload elements, rejects stale revisions, and dispatches framework-neutral DOM events.
lib/solid_objects/turbo_stream_renderer.rb Renders payload Turbo Streams and metadata-only revision invalidations without embedding private observable values.
test/integration/payload_broadcast_test.rb Covers personalization, authorization, fencing metadata, token tampering, payload-only invalidation, and compatibility behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Actor message commits] --> B{Observable changed?}
  B -->|Yes| C[Persist observable broadcast]
  B -->|No, but payload state changed| D[Persist revision-only broadcast]
  C --> E[ActorChannel receives invalidation]
  D --> E
  E --> F[Load committed actor snapshot]
  F --> G[Authorize and compute payload per subscriber]
  G --> H[Transmit payload Turbo Stream]
  H --> I[Dispatch solid-objects:payload event]
Loading

Reviews (2): Last reviewed commit: "fix: invalidate payloads on observable-f..." | Re-trigger Greptile

Comment thread lib/solid_objects/actor_channel.rb
A message that changed state a payload reads without changing a declared observable enqueued no broadcast, so subscribers kept stale personalized state until an unrelated observable changed. Actors with payload broadcasts now enqueue a revision-only broadcast for those commits. The renderer emits invalidation metadata without an observable turbo stream, and the channel does not forward it, so no actor value reaches the browser through this path. Queries still enqueue nothing.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

@cardmagic
cardmagic merged commit 388e59c into main Aug 9, 2026
15 of 16 checks passed
@cardmagic
cardmagic deleted the agent/state-payload-broadcasts branch August 10, 2026 13:52
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.

1 participant