Skip to content

fix: give payload blocks an actor receiver and a context - #26

Merged
cardmagic merged 3 commits into
mainfrom
agent/payload-broadcast-ergonomics
Aug 10, 2026
Merged

fix: give payload blocks an actor receiver and a context#26
cardmagic merged 3 commits into
mainfrom
agent/payload-broadcast-ergonomics

Conversation

@cardmagic

Copy link
Copy Markdown
Owner

Fixes the three payload defects reported from a Rails integration, plus the failure isolation that made them hard to diagnose.

Root cause

All three trace back to one thing: the Action Cable payload delivery path had no end-to-end test. test/integration/payload_broadcast_test.rb exercised PayloadBroadcast as a unit and passed it a hand-built context object; nothing subscribed a channel and asserted what came out. Every defect below lives in the gap between those two.

1 and 2, raw connection as authorization context. Components are refreshed by the engine controller, which resolves component_authorization_context. Payloads are computed inside ActorChannel, which had no equivalent and passed connection straight through to both authorize_query and the payload block. So one path handed the app Playmat::Authorization and the other handed it an Action Cable connection, and the app's hook had to branch.

3, class context as self. PayloadBroadcast#rendered_payload called handler.block.call(snapshot.actor, authorization_context). Every other block in the actor DSL (message, observable, query, on_activate, on_deactivate) goes through instance_exec on the actor instance. broadcast_payload was the only one that did not, so self stayed whatever it was at definition time: the class. Hence NoMethodError: undefined method 'payload_session_id' for class PlaymatRoom.

4, silent payload loss. transmit_state_payloads rescued only Unauthorized. Anything else propagated. On subscribed that landed outside the rescue KeyError, ... UnknownActorType list, so Action Cable rejected the subscription. On a broadcast it abandoned the remaining payload names but left component refreshes already transmitted, which is exactly the reported symptom: components keep updating, payloads never arrive.

API and compatibility

Change Compatibility
configuration.payload_authorization_context New. Defaults to ->(connection:) { connection }, so an unconfigured app behaves exactly as before. May also accept payload_name:.
Payload block receiver is the actor instance Signature unchanged: the block still receives (actor, authorization_context). Documented two-argument blocks are unaffected.
solid_objects.payload_broadcast_failed New event: actor_type, actor_id, payload_name, error_class.
Failures no longer propagate A raising payload no longer rejects the subscription or stops other payloads and components.

The one behavior change is the receiver. A block that reached a class method through implicit self worked before and does not now. Rather than leave that as an unexplained NameError, it raises InvalidPayloadBroadcast naming the method and the change. An ordinary typo still raises NameError, tested both ways.

Files changed

  • lib/solid_objects/payload_broadcast.rbinstance_exec on the actor, receiver diagnostic
  • lib/solid_objects/actor_channel.rb — per-payload isolation, instrumentation, context resolution
  • lib/solid_objects/configuration.rbpayload_authorization_context and its validation
  • lib/solid_objects/callable_keywords.rb — new; keyword detection shared by both resolvers
  • app/controllers/solid_objects/components_controller.rb — uses the shared helper so the two cannot drift
  • lib/generators/solid_objects/templates/solid_objects.rb, docs/realtime.md, docs/roadmap.md, CHANGELOG.md
  • test/integration/payload_delivery_test.rb — new, 17 tests

Test results

bundle exec rake                          364 runs, 1246 assertions, 0 failures, 0 errors, 13 skips
MySQL      (docker, 3307)                 364 runs, 1207 assertions, 0 failures, 0 errors, 22 skips
PostgreSQL (17.6)                         364 runs, 1226 assertions, 0 failures, 0 errors, 13 skips
npm test                                  26 pass

Every requested case is covered: controller authorization context (already in actor_helper_test.rb, left intact), Cable authorization context, block execution context, successful personalized delivery, authorization rejection, block exceptions, continued component delivery after a payload failure, initial/reconnect delivery, and revision de-duplication.

All 17 new tests were written first and observed failing against the current code, including NoMethodError: undefined method 'hand_for' for class PayloadDeliveryTest::RoomActor, the reported failure reproduced verbatim. Each fix was then re-checked by mutation:

Mutation Tests that failed
pass connection instead of the resolved context 3
remove the per-payload rescue and instrumentation 5
revert instance_exec to block.call 2

Upgrade notes

Existing broadcast_payload blocks need no change. Two things are worth doing:

  1. Configure the resolver if your authorization hook currently branches on whether it received a Cable connection. Pair it with the component resolver so both produce the same type:

    configuration.component_authorization_context = ->(controller:) { controller.current_account }
    configuration.payload_authorization_context = ->(connection:) { connection.current_account }

    Then authorize_query and the payload block always see current_account, and the manual connection.playmat_session_id → Playmat::Authorization translation goes away.

  2. Subscribe to the failure event. Silent payload loss was the hardest part of this to diagnose:

    ActiveSupport::Notifications.subscribe("solid_objects.payload_broadcast_failed") do |event|
      Rails.logger.error(event.payload)
    end

If a payload block called a class method through implicit self, give it an explicit receiver. The error now says so directly. An unauthorized payload is still skipped silently, by design: skipping is the security contract, and instrumenting refusals would make an ordinary denial look like a fault.

Three defects reported from a Rails integration, all in the payload path
that had no end-to-end coverage.

Payload blocks ran through `block.call(actor, context)` while every other
block in the actor DSL runs through `instance_exec` on the actor. `self`
was therefore the actor class, and an actor instance method called from a
payload block raised NoMethodError against the class. They now
instance_exec and still receive both arguments, so the documented
signature is unchanged.

Payloads are computed inside the channel rather than a controller, so
they had no counterpart to `component_authorization_context`. The block
and its `authorize_query` call received the raw Cable connection while a
controller render passed an application object, leaving the application's
hook to translate between them. `payload_authorization_context` resolves
the connection once, defaults to the identity, and may also accept
`payload_name:`.

A raising payload block propagated out of the channel. On subscribe it
rejected the subscription; on a broadcast it abandoned the remaining
payload names. The browser saw only reactive updates that stopped
arriving. Failures are now confined per payload name and reported as
solid_objects.payload_broadcast_failed. The exception message is excluded
on purpose: a payload block reads subscriber state, so its message is the
one place that state could reach logs.

The keyword detection behind both resolvers moves to CallableKeywords so
the two cannot drift.
@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown

Greptile Summary

The PR makes personalized payload delivery consistent with the actor DSL and component authorization path while isolating individual delivery failures.

  • Executes payload blocks against actor instances and diagnoses incompatible implicit class-method calls.
  • Adds configurable per-payload authorization-context resolution using shared callable-keyword inspection.
  • Isolates and instruments payload failures while retaining failed revisions for retry.
  • Adds end-to-end Action Cable payload-delivery coverage and updates generated signatures and documentation.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
lib/solid_objects/actor_channel.rb Resolves payload authorization contexts, confines failures per payload, and advances the delivery watermark only after all names settle.
lib/solid_objects/payload_broadcast.rb Executes payload blocks against actor instances and adds a targeted compatibility diagnostic for implicit class-method calls.
lib/solid_objects/callable_keywords.rb Centralizes optional-keyword detection for the component and payload context resolvers.
lib/solid_objects/configuration.rb Adds and validates the payload authorization-context resolver with a backward-compatible connection default.
app/controllers/solid_objects/components_controller.rb Reuses shared callable-keyword inspection without changing component resolver compatibility.
test/integration/payload_delivery_test.rb Adds end-to-end coverage for authorization context, actor receiver behavior, failure isolation, retries, reconnects, and revision deduplication.

Reviews (2): Last reviewed commit: "docs: say what a repeating payload failu..." | Re-trigger Greptile

next
end
payload_names.each { |name| transmit_state_payload(snapshot, name) }
@payload_revision = [ snapshot.instance_id, snapshot.revision ]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Failed payload revision marked delivered

When a resolver, payload block, serialization, or transmission raises, transmit_state_payload suppresses the error but this assignment still marks the entire revision as delivered. Subsequent broadcasts at the same revision are deduplicated, so the subscriber cannot receive the missing payload until the actor advances to a newer revision.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/solid_objects/actor_channel.rb
Line: 82

Comment:
**Failed payload revision marked delivered**

When a resolver, payload block, serialization, or transmission raises, `transmit_state_payload` suppresses the error but this assignment still marks the entire revision as delivered. Subsequent broadcasts at the same revision are deduplicated, so the subscriber cannot receive the missing payload until the actor advances to a newer revision.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 36bb36d. You are right that this was reachable and quiet: @payload_revision advanced whether or not anything was delivered, so a transient failure recorded the revision as done and newer_payload_revision? deduplicated every later attempt at it. Within one connection the subscriber went without that payload until the actor happened to mutate again.

The watermark now advances only when every payload for the revision settled. transmit_state_payload returns whether it settled, and every name is still attempted before the decision is made, so one failure does not skip its siblings.

Unauthorized counts as settled. That decision is stable, so holding the watermark open for it would re-deliver its authorized siblings on every subsequent broadcast at the same revision without ever changing the outcome.

New test "a payload that failed is retried at the same revision" uses a payload that fails only its first attempt, then replays the same broadcast without mutating. It fails against the previous commit with Expected nil to not be nil.

bundle exec rake: 365 runs, 1248 assertions, 0 failures, 0 errors, 13 skips.

@greptileai review

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The fix is correct and the design is sound.

Watermark logic. return if attempts.any?(false) correctly holds the revision open on any transient failure. Unauthorized returning true (settled) is the right call — retrying it would only re-deliver its authorized siblings without changing the outcome, and the comment makes that reasoning explicit.

One edge case worth noting in docs: if a payload block fails persistently (not just transiently), the watermark never advances and every subsequent actor mutation retries all names for that revision indefinitely. That's the correct behavior for a transient error, but for a persistent bug it generates a payload_broadcast_failed event on every broadcast rather than just the first. The upgrade notes already recommend subscribing to that event — worth adding a line that a sustained stream of events for the same payload_name signals a persistent block error rather than a one-off.

Test coverage. Failing only on the first attempt and replaying the broadcast without mutating is the right way to isolate the watermark behavior specifically — it rules out "actor advanced again" as the reason for re-delivery. Clean.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Added in d2151ec. Kept the behaviour and documented the distinction, since retry-until-success is the correct response to a transient failure and the noise is the honest signal for a persistent one: each event means a subscriber really did miss that revision.

The docs now say retries are driven by broadcasts rather than a timer, so a persistent fault retries once per actor mutation and reports every attempt, while a transient one reports once and recovers. A repeating stream for one payload_name means a persistent fault in that block.

@greptileai review

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The documentation lands exactly right. The transient/persistent distinction is stated in operational terms an on-call engineer can act on: one event that doesn't recur → transient, already recovered; repeating stream for one payload_name → persistent fault in that block. The retry cadence (once per actor mutation, not a timer) is also explicit, which matters for sizing the noise.

Two smaller additions I noticed that weren't in scope of the original suggestion but are good additions:

  • The security rationale for omitting the exception message ("a payload block reads subscriber state, so its message is the one place that state could leak into logs") is worth having. That's the kind of "why not the obvious thing" that saves a future reader from filing a bug asking for it.
  • The implicit-receiver example (turn, hand_for without explicit self) shows the instance_exec fix in the one place where it actually matters to a reader writing a payload block for the first time.

Thread is resolved from my side.

Confining a payload failure was only half of it. The watermark that
deduplicates payload delivery advanced whether or not the payload was
delivered, so a transient error marked that revision done: every later
attempt at it was deduplicated away, and the subscriber went without
until the actor happened to mutate again.

The watermark now advances only when every payload for the revision
settled. An unauthorized payload counts as settled, since that decision
is stable and retrying it would only re-deliver its authorized siblings.
Every name is still attempted before the decision is made.
Holding the watermark open on failure means a persistently failing block
retries once per actor mutation and reports every attempt, where a
transient one reports once. An operator watching the event needs to know
which of those they are looking at.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

@cardmagic
cardmagic merged commit 13e83d3 into main Aug 10, 2026
27 checks passed
@cardmagic
cardmagic deleted the agent/payload-broadcast-ergonomics branch August 10, 2026 19:22
@cardmagic cardmagic mentioned this pull request Aug 10, 2026
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