solid_objects 0.4.3 → 0.5.1

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +54 -7
  4. data/app/assets/javascripts/solid_objects/component_refresh.js +124 -0
  5. data/app/controllers/solid_objects/components_controller.rb +31 -9
  6. data/app/helpers/solid_objects/actor_helper.rb +8 -1
  7. data/docs/adr/0009-realtime-updates.md +18 -1
  8. data/docs/architecture.md +34 -18
  9. data/docs/authorization.md +24 -0
  10. data/docs/correctness.md +27 -5
  11. data/docs/operations.md +20 -2
  12. data/docs/realtime.md +78 -13
  13. data/docs/roadmap.md +6 -5
  14. data/examples/application/app/views/chat_rooms/show.html.erb +3 -1
  15. data/lib/solid_objects/actor_view.rb +40 -13
  16. data/lib/solid_objects/component_registration.rb +61 -16
  17. data/lib/solid_objects/component_renderer.rb +14 -17
  18. data/lib/solid_objects/component_subscriptions.rb +7 -7
  19. data/lib/solid_objects/component_token.rb +70 -2
  20. data/lib/solid_objects/database_adapters/sqlite.rb +43 -5
  21. data/lib/solid_objects/doctor.rb +46 -8
  22. data/lib/solid_objects/dom_identity.rb +12 -3
  23. data/lib/solid_objects/engine.rb +7 -0
  24. data/lib/solid_objects/synchronous_invocation.rb +16 -1
  25. data/lib/solid_objects/turbo_stream_renderer.rb +14 -5
  26. data/lib/solid_objects/version.rb +1 -1
  27. data/sig/generated/controllers/solid_objects/components_controller.rbs +6 -0
  28. data/sig/generated/lib/solid_objects/actor_view.rbs +10 -4
  29. data/sig/generated/lib/solid_objects/component_registration.rbs +34 -10
  30. data/sig/generated/lib/solid_objects/component_renderer.rbs +4 -8
  31. data/sig/generated/lib/solid_objects/component_token.rbs +24 -2
  32. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +9 -0
  33. data/sig/generated/lib/solid_objects/doctor.rbs +12 -0
  34. data/sig/generated/lib/solid_objects/dom_identity.rbs +5 -2
  35. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +8 -0
  36. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +3 -0
  37. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e027a73cc399aa779d8a60acae69f3f379ce505e060123ad49d5d64dcda4809
4
- data.tar.gz: 5eb1be4b51649c2c8e6056f0138f130f4f1a8cf2e55e46f86061fc82849658aa
3
+ metadata.gz: 9e251e3648db71bc5e325e1d8a20be55f927ca08208052acebd973eb78e15497
4
+ data.tar.gz: 9362cf7145cb58c02273411dc619a775d7693d238d3f2212d7c4f6336abc30cb
5
5
  SHA512:
6
- metadata.gz: 7f36f021e2707bbd67fcca022a88a4aa70b5cc35f8175c0ee526defcfb66fb1d3113434d8f700aad1f813ca9ccf49bc6a7e59e8d7c95717f007a660ca7535bd4
7
- data.tar.gz: 246e46e83aae40b366992c7c5e763dc65e00945d57b0c0e724595d2f6af0a66ea5c09d5d4dbc5f4c4bdc1ed4de6766d217560e6b376832a98afab158f82e16ea
6
+ metadata.gz: de728511cbd174ba841030f3e29e3dd4245ccd4dd8168523bf132100a8e4ee0c14f02781bf0b29c08a184f3688708f01d8227662068e6a25ebac1ea2b948c72e
7
+ data.tar.gz: a9d34e2ae1defd4288bd9322b3d717ae1fbcc99836f96d76ae2a09d62355e17349089e4b53d9febcefe98aa788c5753b5abcbeee1c9dce08e6968b38297f336e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 - 2026-08-07
4
+
5
+ - Restore the SQLite busy wait that a synchronous invocation suspends for its
6
+ deadline. Rails installs the busy wait as a Ruby busy handler through the
7
+ sqlite3 `timeout` configuration, which `PRAGMA busy_timeout` reports as zero
8
+ and silently replaces, so the previous save and restore left pooled
9
+ connections with no busy handler at all. Every later writer on that
10
+ connection, inside or outside Solid Objects, then failed immediately with
11
+ `SQLite3::BusyException` instead of waiting for the lock. Suspend the busy
12
+ wait only when the adapter can identify how to restore it, so an Active
13
+ Record release that stops exposing the configured timeout loosens
14
+ synchronous deadline bounds instead of stripping lock waiting from a shared
15
+ pooled connection.
16
+
17
+ - Run the doctor round-trip probe on a dedicated caller process, and accept an
18
+ explicit process registry in `SynchronousInvocation`, so the probe can no
19
+ longer stop and delete a shared application caller process, release its
20
+ activations, and unclaim its messages.
21
+ - Report doctor probe cleanup failures as a failed or warned check instead of
22
+ raising a database lock error out of the command and leaking the probe
23
+ caller process.
24
+ - Instrument component refreshes with actor identity, component name, key,
25
+ dependencies, refresh method, revision, and outcome, excluding locals.
26
+
27
+ ## 0.5.0 - 2026-08-07
28
+
29
+ - Add repeatable reactive components with signed string or integer keys and
30
+ JSON-compatible partial locals.
31
+ - Add opt-in Turbo morph refreshes with superseded-request cancellation and
32
+ browser-side actor revision fencing.
33
+ - Pass signed component keys and locals through request-time query
34
+ authorization without broadcasting personalized HTML.
35
+
3
36
  ## 0.4.3 - 2026-08-07
4
37
 
5
38
  - Bound SQLite caller-process registration, reuse, heartbeat, and synchronous
data/README.md CHANGED
@@ -205,9 +205,42 @@ dependencies changes:
205
205
  <% end %>
206
206
  ```
207
207
 
208
+ Component names can repeat when each instance has a stable key. Signed
209
+ JSON-compatible locals let one conventional partial render the matching
210
+ projection:
211
+
212
+ ```erb
213
+ <%= solid_object @room, authorization_context: current_user do |room| %>
214
+ <% @players.each do |player| %>
215
+ <%= room.component :player,
216
+ key: player.id,
217
+ observes: %i[players life_totals],
218
+ locals: { player_id: player.id },
219
+ refresh: :morph %>
220
+ <% end %>
221
+ <% end %>
222
+ ```
223
+
224
+ The host partial still resolves only to `actors/chat_room/_player`. It receives
225
+ `actor`, `authorization_context`, `component_key`, and the declared locals:
226
+
227
+ ```erb
228
+ <article id="player_<%= player_id %>">
229
+ Life: <%= actor.life_totals.fetch(player_id.to_s) %>
230
+ </article>
231
+ ```
232
+
233
+ The default refresh strategy is `:replace`. `refresh: :morph` loads the
234
+ authorized component HTML through a gem-owned browser element, rejects stale
235
+ responses by actor revision, and applies the result using Turbo's scoped
236
+ `replace method="morph"`. Superseded requests for the same keyed target are
237
+ aborted. This preserves unchanged DOM nodes where Turbo's morphing rules allow
238
+ it, including focus and `data-turbo-permanent` content.
239
+
208
240
  `room.component(:messages)` resolves only
209
241
  `actors/chat_room/_messages`. Its partial receives `actor` and
210
- `authorization_context` locals:
242
+ `authorization_context` locals, plus a `component_key` of `nil` when the
243
+ component is unkeyed:
211
244
 
212
245
  ```erb
213
246
  <ul>
@@ -221,7 +254,14 @@ Declared observables are deeply frozen ordinary Ruby values inside a
221
254
  component. Arrays support loops, hashes support ordinary lookup, conditionals
222
255
  work normally, and ERB still escapes user strings. A reactive component cannot
223
256
  read `actor.state`, access an undeclared observable, or choose a dynamic
224
- partial path.
257
+ partial path. A component name and key pair must be unique within its
258
+ `solid_object` scope.
259
+
260
+ Component keys and locals are signed into the refresh token and cannot be
261
+ modified without invalidating it, but they are visible to the browser and are
262
+ not secrets. Every initial render and refresh passes the signed locals and
263
+ `component_key` to `authorize_query` as `arguments`. Authorization must still
264
+ bind them to the authenticated request context.
225
265
 
226
266
  That template provides initial server rendering, stable opaque DOM targets,
227
267
  and live updates after committed actor turns. One `solid_object` block makes
@@ -254,20 +294,27 @@ for the same actor without sharing either projection.
254
294
  Reconnect compares the component's signed initial revision with the latest
255
295
  actor incarnation and state revision, then refreshes stale components. Cable
256
296
  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.
297
+ refresh and ignores older out-of-order invalidations. Replace refreshes detach
298
+ an older in-flight frame. Morph refreshes abort the older request and compare
299
+ the returned revision with the current target before applying HTML.
260
300
 
261
301
  Reactive components add no HTML to durable rows, but each affected component
262
302
  causes an authorized HTTP render. One actor turn still inserts one broadcast
263
303
  row per changed observable; several dependencies from that turn coalesce at
264
304
  the subscriber. Keep components bounded, declare only necessary dependencies,
265
- and use scalar observables for inexpensive single-value replacement.
305
+ keep signed locals small, and use scalar observables for inexpensive
306
+ single-value replacement. Each keyed component counts toward the 50-component
307
+ subscription limit and carries its own signed token.
266
308
 
267
309
  Reactive views require `turbo-rails` and a working Action Cable adapter in the
268
310
  host application. The Solid Objects engine must be mounted so its signed
269
311
  component endpoint is reachable. Reactive views are optional; the actor
270
- runtime itself does not depend on Turbo.
312
+ runtime itself does not depend on Turbo. Morph components automatically include
313
+ the engine's `solid_objects/component_refresh` JavaScript module; the host does
314
+ not need a Stimulus controller or custom stream action. The default Rails
315
+ Propshaft and Sprockets setups discover namespaced engine assets automatically.
316
+ An application created with `--skip-asset-pipeline` should use replace refreshes
317
+ unless it explicitly serves that module.
271
318
 
272
319
  ```ruby
273
320
  # config/routes.rb
@@ -0,0 +1,124 @@
1
+ const activeRefreshes = new Map()
2
+
3
+ class SolidObjectsRefreshElement extends HTMLElement {
4
+ connectedCallback() {
5
+ if (this.dataset.started === "true") return
6
+
7
+ this.dataset.started = "true"
8
+ this.refresh()
9
+ }
10
+
11
+ disconnectedCallback() {
12
+ this.refreshController?.abort()
13
+ }
14
+
15
+ async refresh() {
16
+ const targetName = this.dataset.target
17
+ const source = this.dataset.source
18
+ if (!targetName || !document.getElementById(targetName) || !source) {
19
+ return this.remove()
20
+ }
21
+
22
+ const previousRefresh = activeRefreshes.get(targetName)
23
+ previousRefresh?.abort()
24
+
25
+ const refresh = new AbortController()
26
+ this.refreshController = refresh
27
+ activeRefreshes.set(targetName, refresh)
28
+
29
+ try {
30
+ const sourceUrl = this.sourceUrl(source)
31
+ const response = await fetch(sourceUrl, {
32
+ credentials: "same-origin",
33
+ headers: {
34
+ Accept: "text/html",
35
+ "Turbo-Frame": targetName
36
+ },
37
+ redirect: "error",
38
+ signal: refresh.signal
39
+ })
40
+ if (!response.ok) {
41
+ this.dispatchRefreshError(`http_${response.status}`)
42
+ return
43
+ }
44
+
45
+ const responseDocument = new DOMParser().parseFromString(
46
+ await response.text(),
47
+ "text/html"
48
+ )
49
+ const replacement = responseDocument.getElementById(targetName)
50
+ if (!replacement || replacement.tagName !== "TURBO-FRAME") {
51
+ this.dispatchRefreshError("missing_frame")
52
+ return
53
+ }
54
+ const currentTarget = document.getElementById(targetName)
55
+ if (!currentTarget || !newerRevision(replacement, currentTarget)) return
56
+
57
+ renderMorph(targetName, replacement)
58
+ } catch (error) {
59
+ if (error.name !== "AbortError") {
60
+ this.dispatchRefreshError("request_failed")
61
+ }
62
+ } finally {
63
+ if (activeRefreshes.get(targetName) === refresh) {
64
+ activeRefreshes.delete(targetName)
65
+ }
66
+ this.remove()
67
+ }
68
+ }
69
+
70
+ sourceUrl(source) {
71
+ const sourceUrl = new URL(source, window.location.href)
72
+ if (sourceUrl.origin === window.location.origin) return sourceUrl
73
+
74
+ throw new Error("cross_origin_source")
75
+ }
76
+
77
+ dispatchRefreshError(reason) {
78
+ this.dispatchEvent(
79
+ new CustomEvent("solid-objects:component-refresh-error", {
80
+ bubbles: true,
81
+ detail: { reason }
82
+ })
83
+ )
84
+ }
85
+ }
86
+
87
+ function newerRevision(candidate, current) {
88
+ const candidateRevision = revisionFor(candidate)
89
+ const currentRevision = revisionFor(current)
90
+ if (!candidateRevision || !currentRevision) return false
91
+
92
+ return candidateRevision[0] > currentRevision[0] ||
93
+ (candidateRevision[0] === currentRevision[0] &&
94
+ candidateRevision[1] > currentRevision[1])
95
+ }
96
+
97
+ function revisionFor(element) {
98
+ const revision = element.dataset.solidObjectsRevision
99
+ if (!revision) return
100
+
101
+ const values = revision.split(":").map(Number)
102
+ if (
103
+ values.length !== 2 ||
104
+ values.some((value) => !Number.isSafeInteger(value) || value < 0)
105
+ ) return
106
+
107
+ return values
108
+ }
109
+
110
+ function renderMorph(targetName, replacement) {
111
+ const stream = document.createElement("turbo-stream")
112
+ stream.setAttribute("action", "replace")
113
+ stream.setAttribute("method", "morph")
114
+ stream.setAttribute("target", targetName)
115
+
116
+ const template = document.createElement("template")
117
+ template.content.append(document.importNode(replacement, true))
118
+ stream.append(template)
119
+ document.documentElement.append(stream)
120
+ }
121
+
122
+ if (!customElements.get("solid-objects-refresh")) {
123
+ customElements.define("solid-objects-refresh", SolidObjectsRefreshElement)
124
+ }
@@ -8,12 +8,25 @@ module SolidObjects
8
8
 
9
9
  # @rbs () -> void
10
10
  def show
11
+ SolidObjects.instrument(:"component.refreshed") { |payload| refresh(payload) }
12
+ end
13
+
14
+ private
15
+
16
+ # @rbs (Hash[Symbol, untyped]) -> void
17
+ def refresh(payload)
11
18
  registration = ComponentRegistration.from_token(
12
19
  params.require(:token)
13
20
  )
21
+ payload.merge!(registration_payload(registration))
14
22
  requested_revision = requested_revision_key
15
23
  snapshot = ActorSnapshot.new(registration.reference)
16
- return head :conflict if newer_than_snapshot?(requested_revision, snapshot)
24
+ payload[:instance_id] = snapshot.instance_id
25
+ payload[:revision] = snapshot.revision
26
+ if newer_than_snapshot?(requested_revision, snapshot)
27
+ payload[:outcome] = "conflict"
28
+ return head :conflict
29
+ end
17
30
 
18
31
  authorization_context = SolidObjects
19
32
  .configuration
@@ -21,24 +34,37 @@ module SolidObjects
21
34
  .call(controller: self)
22
35
  rendered = ComponentRenderer.new(
23
36
  snapshot:,
24
- component_name: registration.component_name,
25
- dependencies: registration.dependencies,
37
+ registration:,
26
38
  view_context: component_view_context,
27
39
  authorization_context:
28
40
  ).call
29
41
  response.headers["Cache-Control"] = "private, no-store"
42
+ payload[:outcome] = "rendered"
30
43
  render html: component_frame(registration, snapshot, rendered)
31
44
  rescue Unauthorized
45
+ payload[:outcome] = "unauthorized"
32
46
  head :forbidden
33
47
  rescue UnknownComponent
48
+ payload[:outcome] = "unknown_component"
34
49
  head :not_found
35
50
  rescue ActionController::ParameterMissing,
36
51
  ArgumentError,
37
52
  InvalidComponentToken
53
+ payload[:outcome] = "invalid_token"
38
54
  head :bad_request
39
55
  end
40
56
 
41
- private
57
+ # @rbs (ComponentRegistration) -> Hash[Symbol, untyped]
58
+ def registration_payload(registration)
59
+ {
60
+ actor_type: registration.reference.actor_type,
61
+ actor_id: registration.reference.actor_id,
62
+ component_name: registration.component_name,
63
+ component_key: registration.component_key,
64
+ dependencies: registration.dependencies,
65
+ refresh_method: registration.refresh_method
66
+ }
67
+ end
42
68
 
43
69
  # @rbs () -> Array[Integer]
44
70
  def requested_revision_key
@@ -67,12 +93,8 @@ module SolidObjects
67
93
 
68
94
  # @rbs (ComponentRegistration, ActorSnapshot, untyped) -> String
69
95
  def component_frame(registration, snapshot, rendered)
70
- target = DomIdentity.component(
71
- registration.reference,
72
- registration.component_name
73
- )
74
96
  revision = "#{snapshot.instance_id}:#{snapshot.revision}"
75
- %(<turbo-frame id="#{target}" data-solid-objects-revision="#{revision}">#{rendered}</turbo-frame>).html_safe
97
+ %(<turbo-frame id="#{registration.dom_id}" data-solid-objects-revision="#{revision}" data-solid-objects-refresh="#{registration.refresh_method}">#{rendered}</turbo-frame>).html_safe
76
98
  end
77
99
  end
78
100
  end
@@ -23,10 +23,17 @@ module SolidObjects
23
23
  channel: "SolidObjects::ActorChannel",
24
24
  data: subscription_data
25
25
  )
26
+ refresh_client = if actor.morph_components?
27
+ javascript_include_tag(
28
+ "solid_objects/component_refresh",
29
+ type: "module",
30
+ data: { turbo_track: "reload" }
31
+ )
32
+ end
26
33
 
27
34
  content_tag(
28
35
  :div,
29
- safe_join([ subscription, content ]),
36
+ safe_join([ refresh_client, subscription, content ].compact),
30
37
  id: DomIdentity.scope(reference)
31
38
  )
32
39
  end
@@ -11,11 +11,28 @@ Action Cable broadcasts are online-only. A transaction can roll back, a broadcas
11
11
 
12
12
  The executor evaluates declared observables before and after a successful message. Changed values create broadcast outbox records inside the message commit. A broadcast worker delivers Turbo Stream replacements after commit.
13
13
 
14
- One `solid_object` block creates one signed Action Cable subscription and contains stable targets for multiple observables and components. Subscription authorization runs after token verification and before streaming. Reconnect refresh reads current actor state; the broadcast stream is an optimization, not state.
14
+ One `solid_object` block creates one signed Action Cable subscription and
15
+ contains stable targets for multiple observables and components. Component
16
+ names may repeat behind signed string or integer keys. Small JSON locals,
17
+ dependencies, refresh strategy, and initial revision are signed into each
18
+ component registration.
19
+
20
+ Subscription authorization runs after token verification and before streaming.
21
+ Every initial or request-time component render separately authorizes the
22
+ component name and dependencies with its signed key and locals. Personalized
23
+ HTML is never stored or broadcast.
24
+
25
+ Replace refreshes use Turbo Frames. Optional morph refreshes use a gem-owned
26
+ browser element to fetch the same authorized endpoint, abort superseded
27
+ requests, reject stale revisions, and apply Turbo's scoped morph operation.
28
+ Reconnect refresh reads current actor state; the broadcast stream is an
29
+ optimization, not state.
15
30
 
16
31
  ## Consequences
17
32
 
18
33
  - Disconnected clients may miss individual broadcasts but can converge by refresh.
19
34
  - Broadcast delivery is at least once and replacements must be idempotent.
20
35
  - Actor IDs and signed stream names are identifiers, not authorization.
36
+ - Component keys and locals are visible integrity-protected inputs, not
37
+ secrets or capabilities.
21
38
  - Realtime support is optional and loaded only when Action Cable and Turbo are present.
data/docs/architecture.md CHANGED
@@ -473,13 +473,20 @@ emits:
473
473
  - Stable child target IDs for values and components
474
474
  - A signed actor token used by the channel subscription
475
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
476
+ optional string or integer key, JSON locals, explicit observable
477
+ dependencies, refresh strategy, the initial actor incarnation/revision, and
478
+ a same-origin engine refresh path
478
479
 
479
480
  ```erb
480
- <%= solid_object current_cart do |cart| %>
481
- Cart items: <%= cart.items_count %>
482
- <%= cart.component :summary, observes: %i[items checkout_status] %>
481
+ <%= solid_object @room do |room| %>
482
+ Present: <%= room.presence %>
483
+ <% @players.each do |player| %>
484
+ <%= room.component :player,
485
+ key: player.id,
486
+ observes: :players,
487
+ locals: { player_id: player.id },
488
+ refresh: :morph %>
489
+ <% end %>
483
490
  <% end %>
484
491
  ```
485
492
 
@@ -487,9 +494,11 @@ The signed token proves integrity, not authorization. `ActorChannel#subscribed`
487
494
 
488
495
  Scalar observable calls remain direct escaped Turbo replacements. A reactive
489
496
  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.
497
+ declared observables and signed locals as frozen Ruby values, and cannot read
498
+ raw state or a dependency it did not declare. Repeated component names use a
499
+ keyed digest in the DOM identity, subscription revision map, and duplicate
500
+ check. A static initial-only component can still use a server-selected
501
+ explicit partial; a reactive component cannot.
493
502
 
494
503
  Broadcast replacements happen after the actor transaction commits because only
495
504
  a committed broadcast outbox row can be delivered. Multiple scalar values and
@@ -502,24 +511,29 @@ dependencies do not send their values to the browser, and the stream never
502
511
  contains personalized component HTML. For each subscription, `ActorChannel`
503
512
  matches the changed observable to registered component dependencies. It
504
513
  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.
514
+ revision pairs independently for every component name and key pair. A default
515
+ component invalidation replaces its stable target with a Turbo Frame whose
516
+ source is the signed engine endpoint. A morph invalidation appends a temporary
517
+ gem-owned refresh element carrying the same signed URL.
507
518
 
508
519
  The browser then makes an ordinary cookie-bearing HTTP request. The engine
509
520
  controller derives a request-specific context through
510
521
  `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.
522
+ name and every declared dependency with the signed key and locals as
523
+ authorization arguments, renders the host partial from a new committed
524
+ snapshot, and returns `private, no-store` HTML. Subscribers to the same actor
525
+ can therefore receive different HTML without sharing it through Cable or the
526
+ database.
515
527
 
516
528
  Each channel subscription transmits current scalar replacements and compares
517
529
  each component's signed initial revision against the latest committed
518
530
  `(instance_id, state_revision)` pair, including after reconnect. Missing a
519
531
  broadcast therefore creates temporary staleness, not permanent divergence.
520
532
  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.
533
+ Replace refreshes detach an older in-flight frame. Morph refreshes abort a
534
+ superseded fetch for the same target, re-read the current DOM target after the
535
+ response arrives, compare monotonic revision pairs, and apply authorized HTML
536
+ through Turbo's scoped morph operation only when it is newer.
523
537
 
524
538
  ## Authorization
525
539
 
@@ -540,8 +554,10 @@ No controller, channel, or administrative command treats an actor ID, message ID
540
554
 
541
555
  Initial component rendering, Cable subscription, and request-time component
542
556
  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.
557
+ tokens constrain actor identity, component convention, optional key and
558
+ locals, dependencies, refresh method, revision, and same-origin refresh path
559
+ but never grant access. Keys and locals are browser-visible integrity-protected
560
+ inputs, not encrypted capabilities.
545
561
 
546
562
  Actor IDs are bounded UTF-8 strings and never become constant names, SQL identifiers, file paths, or raw stream names.
547
563
 
@@ -57,6 +57,30 @@ The stream token also signs the scalar observable targets rendered into that
57
57
  specific scope. Component-only dependencies send invalidation metadata but not
58
58
  their state value to the browser.
59
59
 
60
+ Keyed components sign their `component_key` and declared JSON-compatible
61
+ locals into the component token. Initial rendering and every refresh pass those
62
+ values to `authorize_query` as `arguments`; unkeyed components without locals
63
+ retain an empty arguments hash. This lets a policy authorize a projection such
64
+ as one seat or player:
65
+
66
+ ```ruby
67
+ configuration.authorize_query = lambda do |actor_type:, actor_id:, message_name:, arguments:, authorization_context:|
68
+ user = authorization_context
69
+ player_id = arguments["player_id"]
70
+
71
+ actor_type == "PlaymatRoom" &&
72
+ user.present? &&
73
+ user.can_view_room?(actor_id) &&
74
+ (player_id.nil? || user.can_view_player?(player_id))
75
+ end
76
+ ```
77
+
78
+ The values are signed but not encrypted. They are present in server-rendered
79
+ HTML and the Cable subscription identifier, so they must not contain secrets
80
+ or sensitive state. A valid signature proves that the server issued the
81
+ registration; it does not prove the current user may still read it. Always
82
+ reauthorize against the current request context.
83
+
60
84
  ## A tenant-aware policy
61
85
 
62
86
  Pass the authenticated user as the call context:
data/docs/correctness.md CHANGED
@@ -137,10 +137,18 @@ its name and revision over Cable, not its serialized value.
137
137
 
138
138
  Cable compares `(instance_id, state_revision)` pairs, coalesces dependencies
139
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.
140
+ invalidation advances each keyed component registration independently. Replace
141
+ refreshes replace the whole Turbo Frame generation, so a response owned by the
142
+ detached older frame cannot overwrite the current frame. Morph refreshes abort
143
+ a superseded request for the same target and compare the response revision
144
+ with the current DOM revision immediately before applying Turbo's scoped
145
+ morph. Reconnect compares every component's signed initial pair with the
146
+ current instance row and requests the latest committed snapshot when stale.
147
+
148
+ Component keys, JSON locals, dependencies, and refresh strategy are covered by
149
+ the signed registration. Keys and locals are visible to the browser and are
150
+ passed back to `authorize_query` on every render; integrity never substitutes
151
+ for request-specific authorization.
144
152
 
145
153
  ## Synchronous invocation
146
154
 
@@ -159,7 +167,21 @@ result. Adapter lock/query deadlines cover the durable enqueue, caller-process
159
167
  registration and heartbeat, activation coordination, and result observation.
160
168
  SQLite retries busy coordination operations only within the original call
161
169
  deadline and reports `waiting_on=database_contention` when the database cannot
162
- be inspected at timeout. If enqueue cannot commit, `SyncEnqueueTimeout` is
170
+ be inspected at timeout. To keep those retries in Ruby, the SQLite adapter
171
+ suspends the connection's busy wait for the duration of each deadline-bound
172
+ transaction and restores it afterwards. Restoration reinstalls the Ruby busy
173
+ handler Rails configures from the sqlite3 `timeout` setting, which
174
+ `PRAGMA busy_timeout` neither reports nor preserves, so a synchronous call
175
+ leaves the connection's lock waiting behaviour exactly as it found it for
176
+ later writers inside and outside Solid Objects.
177
+
178
+ The adapter suspends the busy wait only when it can identify how to restore
179
+ it. When a future Active Record release stops exposing the configured
180
+ timeout, the adapter leaves the connection untouched: synchronous deadlines
181
+ lose their tight bound and wait as long as the configured busy wait allows,
182
+ rather than stripping lock waiting from a pooled connection the rest of the
183
+ application shares. A test asserts the timeout stays discoverable so the
184
+ looser bound cannot be adopted silently. If enqueue cannot commit, `SyncEnqueueTimeout` is
163
185
  raised and no message reference exists. MySQL lock waits have one-second InnoDB
164
186
  granularity. Ruby handlers that already started are not preempted.
165
187
 
data/docs/operations.md CHANGED
@@ -15,6 +15,14 @@ so the schema check compares the required shape instead of a fixed timestamp.
15
15
  Warnings such as an all-deny neutral policy do not fail the command because a
16
16
  context-aware production policy may correctly deny the probe.
17
17
 
18
+ The round-trip probe runs on its own dedicated caller process rather than the
19
+ shared application caller process, and removes that record together with its
20
+ temporary actor when it finishes. Running the doctor inside a process that
21
+ already serves synchronous calls therefore leaves the application caller
22
+ process, its activations, and its claimed messages untouched, including when an
23
+ application call overlaps the probe. A database busy enough to block cleanup
24
+ reports a failed or warned check rather than raising out of the command.
25
+
18
26
  ## Runtime
19
27
 
20
28
  Start all configured roles:
@@ -147,10 +155,20 @@ transaction rejection, commit-action start/completion/failure, effect and
147
155
  broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
148
156
  retention pruning, process cleanup, and supervisor lifecycle.
149
157
 
158
+ `solid_objects.component.refreshed` covers every authorized component refresh
159
+ request. Its payload carries the actor identity, `component_name`,
160
+ `component_key`, declared `dependencies`, `refresh_method`, the rendered
161
+ `instance_id` and `revision`, and an `outcome` of `rendered`, `conflict`,
162
+ `unauthorized`, `unknown_component`, or `invalid_token`. Use it to watch
163
+ refresh rate per key, authorization denials, superseded requests, and render
164
+ duration. A rejected token reports only the outcome, since no signed identity
165
+ was recovered.
166
+
150
167
  Payloads contain stable runtime identifiers, actor identity, sequence,
151
168
  attempts, ownership generations, and safe exception summaries where relevant.
152
- Arguments, actor state, results, and outbox payloads are excluded. The bundled
153
- log subscriber turns the same notifications into structured logger hashes.
169
+ Arguments, component locals, actor state, results, and outbox payloads are
170
+ excluded. The bundled log subscriber turns the same notifications into
171
+ structured logger hashes.
154
172
 
155
173
  ## Retention and backups
156
174