solid_objects 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +134 -25
  4. data/app/controllers/solid_objects/components_controller.rb +78 -0
  5. data/app/helpers/solid_objects/actor_helper.rb +13 -4
  6. data/config/routes.rb +1 -0
  7. data/db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb +12 -0
  8. data/docs/architecture.md +61 -15
  9. data/docs/authorization.md +38 -1
  10. data/docs/correctness.md +28 -2
  11. data/docs/database-schema.md +13 -5
  12. data/docs/realtime.md +106 -12
  13. data/docs/roadmap.md +6 -5
  14. data/examples/application/README.md +5 -4
  15. data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +1 -1
  16. data/examples/application/app/views/chat_rooms/show.html.erb +2 -2
  17. data/examples/application/config/initializers/solid_objects.rb +9 -3
  18. data/lib/generators/solid_objects/templates/solid_objects.rb +3 -0
  19. data/lib/solid_objects/actor.rb +11 -0
  20. data/lib/solid_objects/actor_channel.rb +73 -5
  21. data/lib/solid_objects/actor_snapshot.rb +25 -6
  22. data/lib/solid_objects/actor_view.rb +103 -10
  23. data/lib/solid_objects/component_path_resolver.rb +27 -0
  24. data/lib/solid_objects/component_registration.rb +117 -0
  25. data/lib/solid_objects/component_renderer.rb +82 -0
  26. data/lib/solid_objects/component_subscriptions.rb +109 -0
  27. data/lib/solid_objects/component_token.rb +139 -0
  28. data/lib/solid_objects/component_view.rb +67 -0
  29. data/lib/solid_objects/configuration.rb +12 -0
  30. data/lib/solid_objects/database_adapters/sqlite.rb +15 -3
  31. data/lib/solid_objects/errors.rb +12 -0
  32. data/lib/solid_objects/executor.rb +1 -0
  33. data/lib/solid_objects/stream_token.rb +32 -13
  34. data/lib/solid_objects/turbo_stream_renderer.rb +45 -1
  35. data/lib/solid_objects/version.rb +1 -1
  36. data/lib/solid_objects.rb +6 -0
  37. data/sig/generated/controllers/solid_objects/components_controller.rbs +22 -0
  38. data/sig/generated/lib/solid_objects/actor.rbs +3 -0
  39. data/sig/generated/lib/solid_objects/actor_channel.rbs +20 -0
  40. data/sig/generated/lib/solid_objects/actor_snapshot.rbs +17 -0
  41. data/sig/generated/lib/solid_objects/actor_view.rbs +31 -2
  42. data/sig/generated/lib/solid_objects/component_path_resolver.rbs +13 -0
  43. data/sig/generated/lib/solid_objects/component_registration.rbs +51 -0
  44. data/sig/generated/lib/solid_objects/component_renderer.rbs +39 -0
  45. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +40 -0
  46. data/sig/generated/lib/solid_objects/component_token.rbs +38 -0
  47. data/sig/generated/lib/solid_objects/component_view.rbs +43 -0
  48. data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
  49. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +5 -0
  50. data/sig/generated/lib/solid_objects/errors.rbs +12 -0
  51. data/sig/generated/lib/solid_objects/stream_token.rbs +9 -4
  52. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +10 -0
  53. metadata +16 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48689af6e9b4f08415ddfd549a99d4e7e667e67db5aa4e92bc0052a6d97f51af
4
- data.tar.gz: 82dc95dc346daf7260356996bfadc99c9c8dd713f2c0fa8c5d8f5efe7859021a
3
+ metadata.gz: 557108141818714af50bfee19acda22c9243482e976b50329e5e234aed6d9adb
4
+ data.tar.gz: fe525288022e4bb0630e60bc7ec949e2f61d1ad152fadcaccaeaa41bd1a0376e
5
5
  SHA512:
6
- metadata.gz: d3a4ce2860cca8656919193b2a9bb6b2c2367552653af106db5fd065bd2bf3c7339a543c5b1bc1fdf1bb7d322e88639a6f20cdba2b5cf551b406e629e6d31598
7
- data.tar.gz: '086652b090e80b0c6830b269e8857ef6de1944b2c2ab145340eeb4a8ba97a26875b9a612c5105ae46bff7362477fe20c3abde837aa4b87d27f5fb91bf19e316e'
6
+ metadata.gz: e4414b1f2e0d47f95f9307fec6140894bcd8ebcab6dbdc20f54155c332dea76de2728ca7b0bf71bc13c978f2494e059f2099986757c3b933296d460a2bec7a65
7
+ data.tar.gz: 6e807551d58c7d87465bc55b460386f5b9617e36021ddbdfdcbfd25aac31d9c48a7166a818f5a39be3f52eb6a7cb63efd74223a0512fb7a53dce7c55a2c23ca8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-08-06
4
+
5
+ - Add dependency-driven live ERB components with request-time authorization,
6
+ conventional partial resolution, revision fencing, refresh coalescing, and
7
+ reconnect convergence without broadcasting personalized HTML.
8
+ - Persist a monotonic state revision for secure component refresh ordering.
9
+ - Retry SQLite synchronous lock contention in Ruby so a native busy wait
10
+ cannot starve the thread holding the database lock.
11
+ - Allow maintainers to dispatch CI manually when a push webhook is dropped.
12
+
3
13
  ## 0.3.0 - 2026-08-06
4
14
 
5
15
  - Reject application-record writes from actor handlers and provide registered
data/README.md CHANGED
@@ -74,6 +74,7 @@ tested, but the project does not yet claim production readiness. See
74
74
  - [Cloudflare Durable Objects for Rails](#cloudflare-durable-objects-for-rails)
75
75
  - [Reactive ERB](#reactive-erb)
76
76
  - [Installation](#installation)
77
+ - [Upgrading](#upgrading)
77
78
  - [Worker requirements](#worker-requirements)
78
79
  - [Defining an actor](#defining-an-actor)
79
80
  - [Actor identity](#actor-identity)
@@ -173,42 +174,105 @@ rolled-back state change cannot leak into the page.
173
174
  Define an observable:
174
175
 
175
176
  ```ruby
176
- class ShoppingCart < SolidObjects::Actor
177
- attribute :items, default: -> { [] }
177
+ class ChatRoom < SolidObjects::Actor
178
+ attribute :recent_messages, default: -> { [] }
179
+ attribute :status, default: "open"
178
180
 
179
- observable :items_count do
180
- items.sum { |item| item.fetch("quantity") }
181
+ observable :message_count do
182
+ recent_messages.length
181
183
  end
184
+
185
+ observable :recent_messages
186
+ observable :status
182
187
  end
183
188
  ```
184
189
 
185
- Render it:
190
+ Scalar observables remain stable `<span>` targets:
191
+
192
+ ```erb
193
+ <%= solid_object @room, authorization_context: current_user do |room| %>
194
+ Messages: <%= room.message_count %>
195
+ <% end %>
196
+ ```
197
+
198
+ Reactive components rerender a host ERB partial when one of their explicit
199
+ dependencies changes:
186
200
 
187
201
  ```erb
188
- <%= solid_object current_cart do |cart| %>
189
- Cart items: <%= cart.items_count %>
202
+ <%= solid_object @room, authorization_context: current_user do |room| %>
203
+ <%= room.component :messages, observes: :recent_messages %>
204
+ <%= room.component :presence, observes: %i[recent_messages status] %>
190
205
  <% end %>
191
206
  ```
192
207
 
193
- That template provides initial server rendering, a stable opaque DOM target,
194
- and live Turbo replacements after committed actor turns. One `solid_object`
195
- block makes one Action Cable subscription for all values inside it, and Action
196
- Cable multiplexes subscriptions over the browser's WebSocket.
208
+ `room.component(:messages)` resolves only
209
+ `actors/chat_room/_messages`. Its partial receives `actor` and
210
+ `authorization_context` locals:
211
+
212
+ ```erb
213
+ <ul>
214
+ <% actor.recent_messages.each do |message| %>
215
+ <li><%= message.fetch("body") %></li>
216
+ <% end %>
217
+ </ul>
218
+ ```
219
+
220
+ Declared observables are deeply frozen ordinary Ruby values inside a
221
+ component. Arrays support loops, hashes support ordinary lookup, conditionals
222
+ work normally, and ERB still escapes user strings. A reactive component cannot
223
+ read `actor.state`, access an undeclared observable, or choose a dynamic
224
+ partial path.
225
+
226
+ That template provides initial server rendering, stable opaque DOM targets,
227
+ and live updates after committed actor turns. One `solid_object` block makes
228
+ one Action Cable subscription for all scalar values and components inside it,
229
+ and Action Cable multiplexes subscriptions over the browser's WebSocket.
197
230
 
198
231
  No client-side state store, custom Stimulus controller, channel class, manual
199
232
  broadcast, or one-WebSocket-per-value setup is required. Signed stream tokens
200
- protect integrity, an application policy authorizes every subscription,
201
- broadcasts are delivered from a durable outbox, and reconnecting clients
202
- refresh from current actor state.
233
+ protect integrity, not access. Initial rendering authorizes with the
234
+ `authorization_context` passed to `solid_object`; Cable authorizes with its
235
+ connection; every component refresh authorizes again with a request-specific
236
+ context:
203
237
 
204
- `cart.component(:summary)` supports initial rendering of
205
- `actors/shopping_cart/_summary`. Durable live component replacement and
206
- Turbo append actions are roadmap work; observable replacement is the live path
207
- implemented today.
238
+ ```ruby
239
+ SolidObjects.configure do |configuration|
240
+ configuration.component_authorization_context = ->(controller:) { Current.user }
241
+ end
242
+ ```
243
+
244
+ The durable outbox stores one row per changed observable, never personalized
245
+ HTML. Cable sends invalidation metadata over the shared actor stream, then a
246
+ Turbo Frame requests the component with normal cookies. Only scalar targets
247
+ that the server rendered into this `solid_object` scope are signed into its
248
+ stream token and receive value payloads; component-only dependencies do not
249
+ send their values to the browser. The endpoint renders the latest committed
250
+ snapshot, returns `private, no-store`, and reauthorizes the component name plus
251
+ every declared dependency. Two viewers can therefore receive different HTML
252
+ for the same actor without sharing either projection.
253
+
254
+ Reconnect compares the component's signed initial revision with the latest
255
+ actor incarnation and state revision, then refreshes stale components. Cable
256
+ coalesces several dependency changes from one actor turn into one component
257
+ refresh and ignores older out-of-order invalidations. A newer invalidation
258
+ replaces an in-flight frame, so its detached older response cannot overwrite
259
+ newer state.
260
+
261
+ Reactive components add no HTML to durable rows, but each affected component
262
+ causes an authorized HTTP render. One actor turn still inserts one broadcast
263
+ row per changed observable; several dependencies from that turn coalesce at
264
+ the subscriber. Keep components bounded, declare only necessary dependencies,
265
+ and use scalar observables for inexpensive single-value replacement.
208
266
 
209
267
  Reactive views require `turbo-rails` and a working Action Cable adapter in the
210
- host application. They are optional; the actor runtime itself does not depend
211
- on Turbo.
268
+ host application. The Solid Objects engine must be mounted so its signed
269
+ component endpoint is reachable. Reactive views are optional; the actor
270
+ runtime itself does not depend on Turbo.
271
+
272
+ ```ruby
273
+ # config/routes.rb
274
+ mount SolidObjects::Engine => "/solid_objects"
275
+ ```
212
276
 
213
277
  ## Installation
214
278
 
@@ -274,6 +338,50 @@ can generate the gem RBI with:
274
338
  bundle exec tapioca gem solid_objects
275
339
  ```
276
340
 
341
+ ## Upgrading
342
+
343
+ Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order
344
+ notes, then update the gem:
345
+
346
+ ```bash
347
+ bundle update solid_objects
348
+ ```
349
+
350
+ If the `Gemfile` pins an exact version, update that constraint first and run
351
+ `bundle install`. Commit both `Gemfile.lock` and the copied Solid Objects
352
+ migrations.
353
+
354
+ Copy only migrations that the newer gem has added, migrate, and verify the
355
+ installation:
356
+
357
+ ```bash
358
+ bin/rails solid_objects:install:migrations
359
+ bin/rails db:migrate
360
+ bin/rails solid_objects:doctor
361
+ ```
362
+
363
+ The migration task skips engine migrations already present in the application
364
+ and gives new migrations host-specific timestamps. Inspect the resulting
365
+ `db/migrate/*.solid_objects.rb` files before applying them. Do not rerun
366
+ `generate solid_objects:install` during an upgrade because that also attempts
367
+ to regenerate the application initializer.
368
+
369
+ When Solid Objects uses a separate database configuration named `actors`, copy
370
+ and run migrations through that database's configured migration path:
371
+
372
+ ```bash
373
+ DATABASE=actors bin/rails solid_objects:install:migrations
374
+ bin/rails db:migrate:actors
375
+ bin/rails solid_objects:doctor
376
+ ```
377
+
378
+ For production, back up the actor database and run new migrations before
379
+ starting application or Solid Objects worker processes that require the new
380
+ schema. Restart the web and Solid Objects worker fleet after the bundle and
381
+ schema are current. For releases that change actor state versions, also follow
382
+ the [state migration and rolling-deployment guide](docs/state-migrations.md);
383
+ Rails schema migrations and actor state migrations are separate concerns.
384
+
277
385
  ## Worker requirements
278
386
 
279
387
  Synchronous actors can be adopted without adding a long-running process. Start
@@ -290,7 +398,7 @@ the runtime when the feature introduces asynchronous delivery or outboxes:
290
398
  | `emit` without an actor callback | Effect worker |
291
399
  | `emit` with success or failure callback | Effect worker and actor worker |
292
400
  | Actor-to-actor `async` or `send_to` | Effect worker and actor worker |
293
- | Observable Turbo updates | Broadcast worker, Action Cable, and the actor execution path |
401
+ | Scalar or component Turbo updates | Broadcast worker, Action Cable, and the actor execution path |
294
402
  | Initial `solid_object` server render | No Solid Objects worker; normal Rails rendering |
295
403
 
296
404
  One command starts every Solid Objects role:
@@ -929,7 +1037,7 @@ See the [development guide](docs/development.md) and
929
1037
 
930
1038
  ## Status
931
1039
 
932
- Implemented and tested in 0.3:
1040
+ Implemented and tested in 0.4:
933
1041
 
934
1042
  - Rails engine, install generator, migrations, and `solid_objects` executable;
935
1043
  - actor registry, references, JSON state, and state migrations;
@@ -947,7 +1055,8 @@ Implemented and tested in 0.3:
947
1055
  - one-shot and recurring per-actor reminders;
948
1056
  - authorized actor destruction with fenced stale-write rejection and cascading
949
1057
  durable-work cleanup;
950
- - durable observable broadcasts and authorized Action Cable refresh;
1058
+ - durable observable invalidations, scalar Turbo replacement, and authorized
1059
+ request-time ERB component refresh;
951
1060
  - process registration, heartbeats, caller shutdown, cleanup, and bounded
952
1061
  message/process retention plus opt-in actor-instance expiration;
953
1062
  - an opt-in Minitest helper for actor-state isolation and deterministic async
@@ -961,8 +1070,8 @@ Partially implemented:
961
1070
  run periodic maintenance automatically;
962
1071
  - cross-process wake-up uses polling; PostgreSQL notifications and optional
963
1072
  Redis acceleration are not implemented;
964
- - live observable replacement works, while live component replacement and
965
- Turbo append actions remain future work;
1073
+ - live observable and component replacement work, while Turbo append actions
1074
+ remain future work;
966
1075
  - local admission limits exist, but distributed rate limits and global
967
1076
  admission control do not; and
968
1077
  - administration views and pruning commands exist, but scheduled maintenance
@@ -0,0 +1,78 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "action_controller/base"
4
+
5
+ module SolidObjects
6
+ class ComponentsController < ActionController::Base
7
+ protect_from_forgery with: :exception
8
+
9
+ # @rbs () -> void
10
+ def show
11
+ registration = ComponentRegistration.from_token(
12
+ params.require(:token)
13
+ )
14
+ requested_revision = requested_revision_key
15
+ snapshot = ActorSnapshot.new(registration.reference)
16
+ return head :conflict if newer_than_snapshot?(requested_revision, snapshot)
17
+
18
+ authorization_context = SolidObjects
19
+ .configuration
20
+ .component_authorization_context
21
+ .call(controller: self)
22
+ rendered = ComponentRenderer.new(
23
+ snapshot:,
24
+ component_name: registration.component_name,
25
+ dependencies: registration.dependencies,
26
+ view_context: component_view_context,
27
+ authorization_context:
28
+ ).call
29
+ response.headers["Cache-Control"] = "private, no-store"
30
+ render html: component_frame(registration, snapshot, rendered)
31
+ rescue Unauthorized
32
+ head :forbidden
33
+ rescue UnknownComponent
34
+ head :not_found
35
+ rescue ActionController::ParameterMissing,
36
+ ArgumentError,
37
+ InvalidComponentToken
38
+ head :bad_request
39
+ end
40
+
41
+ private
42
+
43
+ # @rbs () -> Array[Integer]
44
+ def requested_revision_key
45
+ instance_id = Integer(params.fetch(:instance_id), 10)
46
+ revision = Integer(params.fetch(:revision), 10)
47
+ raise ArgumentError if instance_id.negative? || revision.negative?
48
+
49
+ [ instance_id, revision ]
50
+ end
51
+
52
+ # @rbs (Array[Integer], ActorSnapshot) -> bool
53
+ def newer_than_snapshot?(requested_revision, snapshot)
54
+ (requested_revision <=> [ snapshot.instance_id, snapshot.revision ]) == 1
55
+ end
56
+
57
+ # @rbs () -> untyped
58
+ def component_view_context
59
+ if defined?(Rails) && Rails.application
60
+ prepend_view_path(*Rails.application.paths["app/views"].existent)
61
+ end
62
+
63
+ view_context.tap do |context|
64
+ context.extend(Rails.application.helpers) if defined?(Rails) && Rails.application
65
+ end
66
+ end
67
+
68
+ # @rbs (ComponentRegistration, ActorSnapshot, untyped) -> String
69
+ def component_frame(registration, snapshot, rendered)
70
+ target = DomIdentity.component(
71
+ registration.reference,
72
+ registration.component_name
73
+ )
74
+ revision = "#{snapshot.instance_id}:#{snapshot.revision}"
75
+ %(<turbo-frame id="#{target}" data-solid-objects-revision="#{revision}">#{rendered}</turbo-frame>).html_safe
76
+ end
77
+ end
78
+ end
@@ -9,11 +9,20 @@ module SolidObjects
9
9
  view_context: self,
10
10
  authorization_context:
11
11
  )
12
- subscription = tag.turbo_cable_stream_source(
13
- channel: "SolidObjects::ActorChannel",
14
- token: StreamToken.generate(reference)
15
- )
16
12
  content = capture(actor, &block)
13
+ subscription_attributes = {
14
+ channel: "SolidObjects::ActorChannel",
15
+ token: StreamToken.generate(
16
+ reference,
17
+ observables: actor.scalar_observable_names
18
+ )
19
+ }
20
+ if actor.component_tokens.any?
21
+ subscription_attributes[:data] = {
22
+ components: JSON.generate(actor.component_tokens)
23
+ }
24
+ end
25
+ subscription = tag.turbo_cable_stream_source(**subscription_attributes)
17
26
 
18
27
  content_tag(
19
28
  :div,
data/config/routes.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  SolidObjects::Engine.routes.draw do
4
+ get :components, to: "components#show"
4
5
  resources :instances, only: %i[index show]
5
6
  resources :dead_letters, only: %i[index] do
6
7
  post :retry, on: :member
@@ -0,0 +1,12 @@
1
+ # rbs_inline: enabled
2
+
3
+ class AddStateRevisionToSolidObjectsInstances < ActiveRecord::Migration[8.0]
4
+ # @rbs () -> void
5
+ def change
6
+ add_column SolidObjects.table_name(:instances),
7
+ :state_revision,
8
+ :bigint,
9
+ null: false,
10
+ default: 0
11
+ end
12
+ end
data/docs/architecture.md CHANGED
@@ -120,7 +120,11 @@ instance first prevents a claimed reminder from recreating a destroyed actor.
120
120
 
121
121
  ### Broadcast worker
122
122
 
123
- The broadcast worker claims committed observable-change rows, renders idempotent Turbo replacements, broadcasts to a signed actor stream, and records delivery. Current actor state remains the reconnect source of truth.
123
+ The broadcast worker claims committed observable-change rows, renders
124
+ idempotent scalar Turbo replacements with component invalidation metadata,
125
+ broadcasts to a signed actor stream, and records delivery. It never renders
126
+ personalized component HTML. Current actor state remains the reconnect and
127
+ request-time component source of truth.
124
128
 
125
129
  ### Process registry
126
130
 
@@ -288,13 +292,14 @@ Successful completion uses one database transaction:
288
292
  3. Lock the durable message and verify its claimed membership belongs to that owner, activation token, and generation.
289
293
  4. Execute registered same-database commit actions.
290
294
  5. Update native JSON state and state version.
291
- 6. Store the completion timestamp and result on the durable message and delete claimed membership.
292
- 7. Insert staged effects.
293
- 8. Insert or update staged reminders.
294
- 9. Insert staged actor-message outbox rows.
295
- 10. Insert changed-observable broadcast rows.
296
- 11. Update actor last-used time.
297
- 12. Commit.
295
+ 6. Advance the actor state revision to the completed message sequence.
296
+ 7. Store the completion timestamp and result on the durable message and delete claimed membership.
297
+ 8. Insert staged effects.
298
+ 9. Insert or update staged reminders.
299
+ 10. Insert staged actor-message outbox rows.
300
+ 11. Insert changed-observable broadcast rows.
301
+ 12. Update actor last-used time.
302
+ 13. Commit.
298
303
 
299
304
  Any lease or message predicate failure raises `LostActivation` and rolls back every item. The stale worker discards its in-memory activation.
300
305
 
@@ -460,25 +465,61 @@ Large repairs use `async(..., available_at:)` to spread work over an application
460
465
 
461
466
  ## Realtime integration
462
467
 
463
- `solid_object` performs an authorized state read for initial rendering and emits:
468
+ `solid_object` performs an authorized state read for initial rendering and
469
+ emits:
464
470
 
465
471
  - A stable scope DOM ID derived from actor type and a SHA-256 digest of actor ID
466
472
  - One Turbo Cable subscription element for the actor
467
473
  - Stable child target IDs for values and components
468
474
  - A signed actor token used by the channel subscription
475
+ - Signed component registrations containing a conventional component name,
476
+ explicit observable dependencies, the initial actor incarnation/revision,
477
+ and a same-origin engine refresh path
469
478
 
470
479
  ```erb
471
480
  <%= solid_object current_cart do |cart| %>
472
481
  Cart items: <%= cart.items_count %>
473
- <%= cart.component :summary %>
482
+ <%= cart.component :summary, observes: %i[items checkout_status] %>
474
483
  <% end %>
475
484
  ```
476
485
 
477
486
  The signed token proves integrity, not authorization. `ActorChannel#subscribed` verifies the token, resolves the registered actor type, invokes `authorize_subscription`, and only then streams.
478
487
 
479
- Broadcast replacements happen after the actor transaction commits because only a committed broadcast outbox row can be delivered. Multiple values share the same Action Cable connection and one actor subscription.
480
-
481
- Each channel subscription transmits current observable replacements before streaming future broadcasts, including after reconnect. Missing a broadcast therefore creates temporary staleness, not permanent divergence.
488
+ Scalar observable calls remain direct escaped Turbo replacements. A reactive
489
+ component resolves only `actors/<actor_class>/_<component>`, receives its
490
+ declared observables as frozen Ruby values, and cannot read raw state or a
491
+ dependency it did not declare. A static initial-only component can still use a
492
+ server-selected explicit partial; a reactive component cannot.
493
+
494
+ Broadcast replacements happen after the actor transaction commits because only
495
+ a committed broadcast outbox row can be delivered. Multiple scalar values and
496
+ components share the same Action Cable connection and one actor subscription.
497
+ One outbox row still exists per changed observable.
498
+
499
+ The shared stream contains invalidation metadata and scalar HTML only for the
500
+ scalar targets signed into that scope's stream token. Component-only
501
+ dependencies do not send their values to the browser, and the stream never
502
+ contains personalized component HTML. For each subscription, `ActorChannel`
503
+ matches the changed observable to registered component dependencies. It
504
+ coalesces multiple dependencies at the same message sequence and drops older
505
+ revision pairs. A component invalidation replaces its stable target with a
506
+ Turbo Frame whose source is the signed engine endpoint.
507
+
508
+ The browser then makes an ordinary cookie-bearing HTTP request. The engine
509
+ controller derives a request-specific context through
510
+ `component_authorization_context`, calls `authorize_query` for the component
511
+ name and every declared dependency, renders the host partial from a new
512
+ committed snapshot, and returns `private, no-store` HTML. Subscribers to the
513
+ same actor can therefore receive different HTML without sharing it through
514
+ Cable or the database.
515
+
516
+ Each channel subscription transmits current scalar replacements and compares
517
+ each component's signed initial revision against the latest committed
518
+ `(instance_id, state_revision)` pair, including after reconnect. Missing a
519
+ broadcast therefore creates temporary staleness, not permanent divergence.
520
+ The instance primary key distinguishes destroy-and-recreate incarnations.
521
+ Replacing the full frame on each newer invalidation detaches an older in-flight
522
+ frame, preventing its slower response from replacing the current generation.
482
523
 
483
524
  ## Authorization
484
525
 
@@ -497,6 +538,11 @@ recognizable.
497
538
 
498
539
  No controller, channel, or administrative command treats an actor ID, message ID, request ID, or signed stream name as authorization.
499
540
 
541
+ Initial component rendering, Cable subscription, and request-time component
542
+ refresh deliberately use different authorization contexts. Signed component
543
+ tokens constrain actor identity, component convention, dependencies, revision,
544
+ and same-origin refresh path but never grant access.
545
+
500
546
  Actor IDs are bounded UTF-8 strings and never become constant names, SQL identifiers, file paths, or raw stream names.
501
547
 
502
548
  ## Serialization
@@ -639,7 +685,7 @@ PostgreSQL transaction-level advisory locks may be used for optional singleton m
639
685
  | Create actor and allocate message sequence | Instance insert/lock, sequence increment, durable message and ready-membership inserts |
640
686
  | Claim activation | Backend claim transaction, generation increment, owner and expiry |
641
687
  | Claim next message | Move ready membership to claimed membership conditioned on lease |
642
- | Successful message commit | Fenced state, durable message result, claimed-membership deletion, effects, reminders, actor outbox, broadcasts |
688
+ | Successful message commit | Fenced state and monotonic revision, durable message result, claimed-membership deletion, effects, reminders, actor outbox, broadcasts |
643
689
  | Failed message attempt | Conditional error, claimed deletion, ready reinsertion or dead letter |
644
690
  | Renew or release lease | Conditional instance update |
645
691
  | Destroy actor | Instance identity lock and cascading delete of state, mailbox, reminders, and outboxes |
@@ -687,7 +733,7 @@ All backends use unique identity and sequence constraints, short transactions, a
687
733
  19. **How are state migrations performed?** Explicit one-step actor migrations on activation, persisted only with a successful fenced commit.
688
734
  20. **What happens during rolling deploys?** Newer state can make old workers incompatible; deploys must preserve backward readability or drain old workers.
689
735
  21. **How are subscriptions authorized?** Verify signed identity, resolve registered type, invoke host authorization, then stream.
690
- 22. **How are lost broadcasts recovered?** Current-state refresh after reconnect; durable outbox retries server delivery.
736
+ 22. **How are lost broadcasts recovered?** Current-state scalar replacement and authorized component refresh after reconnect; durable outbox retries server delivery.
691
737
  23. **How are actor-to-actor cycles handled?** Synchronous actor waits are rejected; asynchronous request/result messages avoid call-stack cycles.
692
738
  24. **Which operations are transactional?** The transaction map above lists every atomic boundary. Actor code and external I/O are outside; registered same-pool commit actions execute inside the fenced state/message transaction.
693
739
  25. **Which guarantees depend on PostgreSQL?** None of the public semantics are PostgreSQL-only. PostgreSQL and MySQL depend on row-lock claiming; SQLite depends on serialized write transactions. Each backend's guarantee depends on its adapter-specific integration tests.
@@ -10,7 +10,7 @@ intentionally inert until the host application defines its trust boundary.
10
10
  | Policy | Gates | Caller context | Risk if opened globally |
11
11
  | --- | --- | --- | --- |
12
12
  | `authorize_message` | Direct actor methods, explicit `sync` messages, and public `async` enqueue | Value passed as `authorization_context:`; often a user, service principal, or trusted internal marker | Anyone reaching the call site can mutate any known actor identity |
13
- | `authorize_query` | Attribute reads, declared queries, committed snapshots, observable reads, and component reads | Explicit call context or the Rails view context supplied by `solid_object` | Actor state can leak across users or tenants |
13
+ | `authorize_query` | Attribute reads, declared queries, committed snapshots, scalar observable reads, initial component rendering, and every component refresh dependency | Explicit call context, the context passed to `solid_object`, or the request context resolved for a component refresh | Actor state or personalized projections can leak across users or tenants |
14
14
  | `authorize_destroy` | `reference.destroy` | Value passed as `authorization_context:` | Complete actor state, mailbox, reminders, and pending outboxes can be deleted |
15
15
  | `authorize_subscription` | Action Cable subscription to one actor stream | The `ActionCable::Connection` object | Clients can receive future observable updates for other actors |
16
16
  | `authorize_administration` | Engine administration controllers, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable |
@@ -20,6 +20,43 @@ invocation as a message or query. Internal reminder, effect-callback, and
20
20
  actor-to-actor deliveries come from
21
21
  already committed runtime rows and do not re-enter the public client policy.
22
22
 
23
+ ## Realtime authorization contexts
24
+
25
+ Reactive components cross three Rails execution contexts and authorize at all
26
+ three boundaries:
27
+
28
+ 1. `solid_object(..., authorization_context:)` uses the explicit Action View
29
+ render context for initial scalar and component reads.
30
+ 2. `ActorChannel` passes its authenticated `ActionCable::Connection` to
31
+ `authorize_subscription`.
32
+ 3. `ComponentsController` resolves a fresh context for the cookie-bearing HTTP
33
+ request and calls `authorize_query` for the component name and every
34
+ dependency.
35
+
36
+ Configure the refresh resolver when the query policy expects a user or service
37
+ principal rather than the engine controller:
38
+
39
+ ```ruby
40
+ SolidObjects.configure do |configuration|
41
+ configuration.component_authorization_context = lambda do |controller:|
42
+ Current.user
43
+ end
44
+ end
45
+ ```
46
+
47
+ Authentication middleware must populate `Current.user` for the refresh
48
+ request. Do not copy an Action View object or Cable connection into the signed
49
+ token. Those objects are request-specific and the token provides integrity,
50
+ not authorization.
51
+
52
+ Component partials receive the resolved value as the
53
+ `authorization_context` local, allowing two authorized viewers to render
54
+ different projections. Responses use `Cache-Control: private, no-store`.
55
+ Durable outbox rows and shared Cable messages never contain component HTML.
56
+ The stream token also signs the scalar observable targets rendered into that
57
+ specific scope. Component-only dependencies send invalidation metadata but not
58
+ their state value to the browser.
59
+
23
60
  ## A tenant-aware policy
24
61
 
25
62
  Pass the authenticated user as the call context:
data/docs/correctness.md CHANGED
@@ -101,8 +101,8 @@ The following are atomic:
101
101
  - activation owner, expiration, and generation acquisition;
102
102
  - ready-to-claimed membership move and attempt increment;
103
103
  - state, state version, message result/completion, claimed deletion, effects,
104
- same-database commit actions, reminders, outbound actor messages, and
105
- observable broadcasts;
104
+ same-database commit actions, reminders, outbound actor messages, observable
105
+ broadcasts, and the monotonic actor state revision;
106
106
  - failed-attempt record plus ready reinsertion or dead letter;
107
107
  - effect completion plus its optional actor outcome message;
108
108
  - reminder occurrence enqueue plus reminder advancement; and
@@ -116,6 +116,32 @@ it is available only when Solid Objects and `ActiveRecord::Base` share one
116
116
  connection pool. Commit actions must contain only bounded database work.
117
117
  External I/O belongs in the effect outbox.
118
118
 
119
+ ## Reactive components
120
+
121
+ A successful fenced turn advances `instances.state_revision` to that message's
122
+ per-actor sequence and inserts changed-observable broadcast rows in the same
123
+ transaction. A rollback, retryable handler failure, or lost activation advances
124
+ neither. Component HTML is not durable and is never placed on the shared actor
125
+ stream.
126
+
127
+ Each component token signs the actor identity, conventional component name,
128
+ explicit dependencies, initial instance ID and revision, and same-origin
129
+ refresh path. The signature detects modification but grants no access. Initial
130
+ rendering invokes query authorization, Cable separately invokes subscription
131
+ authorization, and the cookie-bearing refresh request invokes query
132
+ authorization again for the component name and every dependency.
133
+
134
+ The actor stream token separately signs the scalar observable targets rendered
135
+ into its scope. A component dependency that has no scalar target carries only
136
+ its name and revision over Cable, not its serialized value.
137
+
138
+ Cable compares `(instance_id, state_revision)` pairs, coalesces dependencies
139
+ changed by the same turn, and ignores an older pair after a newer one. A new
140
+ invalidation replaces the whole Turbo Frame generation. A response owned by
141
+ the detached older frame cannot overwrite the current frame. Reconnect
142
+ compares the component's signed initial pair with the current instance row and
143
+ requests the latest committed snapshot when stale.
144
+
119
145
  ## Synchronous invocation
120
146
 
121
147
  A direct reference method or explicit `sync` call durably enqueues an ordinary
@@ -8,9 +8,15 @@ migrations load. No partial indexes are used.
8
8
  ### `instances`
9
9
 
10
10
  One row per `(actor_type, actor_id)`. Stores JSON state, state version,
11
- next-message sequence, activation owner/token/expiration/generation, pause
12
- state, and lifecycle timestamps. The owner/token pairing is constrained so one
13
- process row cannot make two concurrent activations appear identical.
11
+ monotonic state revision, next-message sequence, activation
12
+ owner/token/expiration/generation, pause state, and lifecycle timestamps. The
13
+ owner/token pairing is constrained so one process row cannot make two
14
+ concurrent activations appear identical.
15
+
16
+ `state_revision` advances to the successful message sequence in the same
17
+ fenced transaction as state and outboxes. Reactive components compare
18
+ `(instance_id, state_revision)` so pruned message history cannot make revisions
19
+ regress and destroy-and-recreate produces a new incarnation.
14
20
 
15
21
  Deleting an instance is the actor-incarnation boundary. Foreign keys cascade
16
22
  the delete through messages, ready and claimed memberships, reminders, effects,
@@ -85,8 +91,10 @@ Status/availability/ID drives delivery; completion/ID drives cleanup.
85
91
  ### `broadcasts`
86
92
 
87
93
  Durable observable-change outbox. The unique message/observable key prevents
88
- duplicate rows for one actor turn. Claim and delivery indexes support retries
89
- and cleanup.
94
+ duplicate rows for one actor turn. Rows contain the observable JSON value and
95
+ message/instance references used to derive invalidation metadata, never
96
+ personalized rendered HTML. Claim and delivery indexes support retries and
97
+ cleanup.
90
98
 
91
99
  ### `dead_letters`
92
100