solid_objects 0.12.1 → 0.13.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +58 -11
  4. data/docs/adr/0009-realtime-updates.md +5 -1
  5. data/docs/architecture.md +3 -2
  6. data/docs/authorization.md +11 -4
  7. data/docs/correctness.md +3 -3
  8. data/docs/dashboard.md +200 -0
  9. data/docs/database-schema.md +5 -4
  10. data/docs/realtime.md +22 -14
  11. data/docs/roadmap.md +24 -6
  12. data/docs/security.md +7 -7
  13. data/examples/application/README.md +5 -5
  14. data/examples/application/app/actors/chat_room_actor.rb +1 -1
  15. data/examples/application/app/actors/shopping_cart_actor.rb +2 -2
  16. data/lib/solid_objects/actor.rb +1 -1
  17. data/lib/solid_objects/version.rb +1 -1
  18. data/lib/solid_objects/web/action.rb +109 -0
  19. data/lib/solid_objects/web/application.rb +239 -0
  20. data/lib/solid_objects/web/csrf_protection.rb +130 -0
  21. data/lib/solid_objects/web/helpers.rb +227 -0
  22. data/lib/solid_objects/web/paginator.rb +64 -0
  23. data/lib/solid_objects/web/route.rb +55 -0
  24. data/lib/solid_objects/web/router.rb +46 -0
  25. data/lib/solid_objects/web/statistics.rb +78 -0
  26. data/lib/solid_objects/web.rb +222 -0
  27. data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
  28. data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
  29. data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
  30. data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
  31. data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
  32. data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
  33. data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
  34. data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
  35. data/sig/generated/lib/solid_objects/web.rbs +129 -0
  36. data/web/assets/javascripts/application.js +118 -0
  37. data/web/assets/javascripts/charts.js +190 -0
  38. data/web/assets/stylesheets/application.css +527 -0
  39. data/web/views/_messages.erb +29 -0
  40. data/web/views/_navigation.erb +15 -0
  41. data/web/views/_paging.erb +17 -0
  42. data/web/views/_status_filter.erb +8 -0
  43. data/web/views/_summary.erb +39 -0
  44. data/web/views/broadcasts.erb +35 -0
  45. data/web/views/dashboard.erb +128 -0
  46. data/web/views/dead_letter.erb +40 -0
  47. data/web/views/dead_letters.erb +42 -0
  48. data/web/views/effects.erb +35 -0
  49. data/web/views/instance.erb +139 -0
  50. data/web/views/instances.erb +49 -0
  51. data/web/views/layout.erb +22 -0
  52. data/web/views/mailbox.erb +35 -0
  53. data/web/views/message.erb +34 -0
  54. data/web/views/processes.erb +37 -0
  55. data/web/views/reminders.erb +37 -0
  56. metadata +55 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e89f40dd095ec1d8934ff47872f3638ca441203186a7a1562dc9f603c939150
4
- data.tar.gz: 23389e8737fcbfc816ca41aedf56f4b83baa6769fc2c54f6d9ca3622c67ca135
3
+ metadata.gz: 8dc01a4c7b7f44d7dbd68b1194abd64a2c5515d5280a1e9cae15b119ff18508c
4
+ data.tar.gz: 68099ea645dff1d976f9aeda77d79d04ed8280e18bfc6d4235daca972ffafad4
5
5
  SHA512:
6
- metadata.gz: d28e8e826c045dc546a13eb34631c692281b82b10a55205f0f6b3a17b77a1a2144bbfb786c69c86d701d42d356c65da4c26d7dd2f25a5538f9743e4405aa53b1
7
- data.tar.gz: 34c89447e1592ea056d511db1fbf01de898c1563c34616a7b80058b0c1bb649b3a4e01cf7f1fad6de35bedc82e484c0a8c96b21aa0be1d959b94c3492fd3d3c4
6
+ metadata.gz: 335cec6b8f87328e9a88eee32bbd8aaca5b019cb0ef76dfa70f5efa261afb81a2a034d3bc6a11189f22c4809583d9cce1538c6ecb1ff8cc8319c1d195fec8295
7
+ data.tar.gz: 445c227d5badd737cb44f9b44b87917b73c3e2a55a66acb6a4cd1fe09a39febda314f00188cd35eabc70acef5d5b95503c3cedf5c35632e22948fa62667a62db
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0 - 2026-08-15
4
+
5
+ - **Breaking:** make observables invalidation-only by default. An ordinary
6
+ `observable :status` continues to detect changes and refresh reactive
7
+ components, but persists `{}` and sends no scalar value over Action Cable.
8
+ Declare `observable :status, broadcast: :value` to deliberately store and
9
+ share the projection with every authorized actor subscriber. Applications
10
+ upgrading from 0.12.x must add that opt-in to observables rendered as scalar
11
+ targets.
12
+ - Add `SolidObjects::Web`, a mountable Rack dashboard for the actor runtime.
13
+ It covers instances and their committed state, the ready and claimed
14
+ mailbox, reminders, effects, broadcasts, dead letters, and processes, with
15
+ actor-type and actor-id filtering, status filters, paging, and a polled
16
+ `GET /stats` endpoint. Mount it with
17
+ `mount SolidObjects::Web => "/solid_objects/dashboard"` after
18
+ `require "solid_objects/web"`; requiring the gem does not load it, so a
19
+ worker process carries no web stack.
20
+ - Authorize every dashboard route through `authorize_administration`. Each
21
+ route declares its own `action` and `resource`, and a route declared without
22
+ a policy raises at load time. The policy receives a context that answers
23
+ `request`, `session`, and `env`.
24
+ - Add two dashboard actions: an idempotent dead letter retry through
25
+ `SolidObjects.dead_letters.retry`, and instance pause/resume, which sets and
26
+ clears `paused_at` so the activation manager stops claiming that identity. A
27
+ retry the mailbox refuses, such as an actor class that no longer exists,
28
+ renders the reason with a 422 rather than failing the request.
29
+ - Draw instances per actor type, mailbox depth, and outbox and reminder status
30
+ with Chart.js, loaded from a CDN with a subresource integrity hash. The CDN
31
+ host is the only external origin the content security policy names. Point
32
+ `SolidObjects::Web.chart_library_url` at a vendored copy for a deployment
33
+ with no outbound network access, or set it to nil to render without charts.
34
+ - Add `SolidObjects::Web.register` for extension tabs, routes, and view
35
+ directories, and `SolidObjects::Web.use` for Rack middleware in front of the
36
+ dashboard.
37
+ - Add `rack` as an explicit dependency at `>= 3.1`, and package the `web/`
38
+ directory in the gem.
39
+
3
40
  ## 0.12.1 - 2026-08-13
4
41
 
5
42
  - Add invalidation-only observables with `broadcast: :invalidation`. They still
data/README.md CHANGED
@@ -86,6 +86,7 @@ tested, but the project does not yet claim production readiness. See
86
86
  - [State migrations](#state-migrations)
87
87
  - [Configuration](#configuration)
88
88
  - [Workers and operations](#workers-and-operations)
89
+ - [Dashboard](#dashboard)
89
90
  - [Database support](#database-support)
90
91
  - [Guarantees](#guarantees)
91
92
  - [When to use it](#when-to-use-it)
@@ -178,7 +179,7 @@ class ChatRoom < SolidObjects::Actor
178
179
  attribute :recent_messages, default: -> { [] }
179
180
  attribute :status, default: "open"
180
181
 
181
- observable :message_count do
182
+ observable :message_count, broadcast: :value do
182
183
  recent_messages.length
183
184
  end
184
185
 
@@ -205,20 +206,22 @@ dependencies changes:
205
206
  <% end %>
206
207
  ```
207
208
 
208
- An observable's value is shared with every authorized subscriber by default
209
- and is stored in `solid_objects_broadcasts`. Use an invalidation-only observable
210
- for component dependencies whose value is private or subscriber-specific:
209
+ Observables are invalidation-only by default. Their values remain available to
210
+ authorized component rendering, while durable rows and Action Cable frames
211
+ carry only change metadata. Explicitly opt a scalar observable into sharing its
212
+ value with every authorized actor subscriber:
211
213
 
212
214
  ```ruby
213
- observable :player_one, broadcast: :invalidation do
214
- player_in_seat(1)
215
+ observable :message_count, broadcast: :value do
216
+ recent_messages.length
215
217
  end
216
218
  ```
217
219
 
218
- Its value is still available to the authorized component renderer, but the
219
- durable row and Action Cable frame carry only invalidation metadata. It cannot
220
- be rendered as a scalar `<span>`. Put per-viewer state in `broadcast_payload`,
221
- which computes a fresh projection for each connection.
220
+ Only `broadcast: :value` observables can render as scalar `<span>` targets.
221
+ Their changed values are stored in `solid_objects_broadcasts` and can reach
222
+ every subscriber that passes `authorize_subscription` for the actor. Put
223
+ per-viewer state in `broadcast_payload`, which computes a fresh projection for
224
+ each connection.
222
225
 
223
226
  Component names can repeat when each instance has a stable key. Signed
224
227
  JSON-compatible locals let one conventional partial render the matching
@@ -530,7 +533,7 @@ class ShoppingCart < SolidObjects::Actor
530
533
  end
531
534
  end
532
535
 
533
- observable :items_count do
536
+ observable :items_count, broadcast: :value do
534
537
  items.sum { |item| item.fetch("quantity") }
535
538
  end
536
539
  end
@@ -1082,6 +1085,50 @@ an initializer.
1082
1085
  See the [operations guide](docs/operations.md) for monitoring, reconciliation,
1083
1086
  shutdown, retention, and backup guidance.
1084
1087
 
1088
+ ## Dashboard
1089
+
1090
+ `SolidObjects::Web` is a Rack application that shows instances and their state,
1091
+ the mailbox, reminders, effects, broadcasts, dead letters, and the registered
1092
+ processes. Mount it inside the application routes, so the Rails session
1093
+ middleware runs first:
1094
+
1095
+ ```ruby
1096
+ # config/routes.rb
1097
+ require "solid_objects/web"
1098
+
1099
+ Rails.application.routes.draw do
1100
+ mount SolidObjects::Web => "/solid_objects/dashboard"
1101
+ end
1102
+ ```
1103
+
1104
+ It is not loaded by `require "solid_objects"`: a worker process must not carry
1105
+ a web stack. The dashboard and the engine are separate mounts, so an
1106
+ application that uses reactive ERB mounts both on different paths.
1107
+
1108
+ Every page asks `authorize_administration` before its handler runs, and that
1109
+ policy denies by default, so a mount alone exposes nothing. The block receives
1110
+ the route's own `action:` and `resource:`, and an `authorization_context:` that
1111
+ answers `request`, `session`, and `env`.
1112
+
1113
+ The dashboard changes only two things. Retrying a dead letter goes through
1114
+ `SolidObjects.dead_letters.retry`, which is idempotent. Pausing an instance
1115
+ sets `paused_at` so the activation manager stops claiming that identity; a pass
1116
+ already in flight finishes its turn, and a synchronous caller waiting on a
1117
+ paused instance times out rather than receiving a result.
1118
+
1119
+ The dashboard draws instances per actor type, mailbox depth, and outbox status
1120
+ with Chart.js, loaded from a CDN with a subresource integrity hash. A
1121
+ deployment with no outbound network access can vendor the file, or turn the
1122
+ charts off:
1123
+
1124
+ ```ruby
1125
+ SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js"
1126
+ SolidObjects::Web.chart_library_integrity = nil
1127
+ ```
1128
+
1129
+ Read the [dashboard guide](docs/dashboard.md) for the full policy table,
1130
+ extension registration, and query cost.
1131
+
1085
1132
  ## Database support
1086
1133
 
1087
1134
  Solid Objects supports:
@@ -9,7 +9,11 @@ Action Cable broadcasts are online-only. A transaction can roll back, a broadcas
9
9
 
10
10
  ## Decision
11
11
 
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.
12
+ The executor evaluates declared observables before and after a successful
13
+ message. Changes create invalidation-only broadcast outbox records inside the
14
+ message commit by default. An explicit `broadcast: :value` declaration stores
15
+ the projection and lets a broadcast worker deliver scalar Turbo Stream
16
+ replacements after commit.
13
17
 
14
18
  One `solid_object` block creates one signed Action Cable subscription and
15
19
  contains stable targets for multiple observables and components. Component
data/docs/architecture.md CHANGED
@@ -169,8 +169,9 @@ turn. `SolidObjects.mutable_copy` creates an independent mutable JSON value.
169
169
  `message` and `query` both execute as durable mailbox turns. A query may not
170
170
  mutate state. The executor detects query mutation and fails the message. An
171
171
  observable is a named projection of state used by server rendering and realtime
172
- updates. Its durable broadcast row stores the projected value by default;
173
- `broadcast: :invalidation` stores only an empty invalidation marker.
172
+ updates. Its durable broadcast row stores only an empty invalidation marker by
173
+ default. `broadcast: :value` explicitly opts into storing and sharing the
174
+ projected value.
174
175
 
175
176
  Lifecycle hooks are deterministic local hooks:
176
177
 
@@ -13,7 +13,7 @@ intentionally inert until the host application defines its trust boundary.
13
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
- | `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 |
16
+ | `authorize_administration` | Engine administration controllers, every `SolidObjects::Web` page, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller, a `SolidObjects::Web` request that answers `request`/`session`/`env`, or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable |
17
17
 
18
18
  Waiting again through `MessageReference#wait` reauthorizes the stored
19
19
  invocation as a message or query. Internal reminder, effect-callback, and
@@ -53,9 +53,9 @@ Component partials receive the resolved value as the
53
53
  `authorization_context` local, allowing two authorized viewers to render
54
54
  different projections. Responses use `Cache-Control: private, no-store`.
55
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.
56
+ The stream token also signs explicitly value-broadcast scalar observable
57
+ targets rendered into that specific scope. Default component dependencies send
58
+ invalidation metadata but not their state value to the browser.
59
59
 
60
60
  Keyed components sign their `component_key` and declared JSON-compatible
61
61
  locals into the component token. Initial rendering and every refresh pass those
@@ -154,6 +154,13 @@ configuration.authorize_administration = lambda do |authorization_context:, **|
154
154
  end
155
155
  ```
156
156
 
157
+ That policy also denies every `SolidObjects::Web` page, which is the correct
158
+ result for a host whose only administration boundary is shell access. A policy
159
+ that opens the dashboard should separate reading from writing, because
160
+ `action` distinguishes them: `index` and `show` read, while `pause`, `resume`,
161
+ and `retry` change the runtime. The [dashboard guide](dashboard.md) lists the
162
+ action and resource of every page.
163
+
157
164
  Run `bin/rails solid_objects:doctor` after configuration. Its neutral policy
158
165
  probe is deliberately conservative: a context-aware policy may correctly warn
159
166
  because it denies a `nil` context.
data/docs/correctness.md CHANGED
@@ -131,9 +131,9 @@ rendering invokes query authorization, Cable separately invokes subscription
131
131
  authorization, and the cookie-bearing refresh request invokes query
132
132
  authorization again for the component name and every dependency.
133
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.
134
+ The actor stream token separately signs the explicitly value-broadcast scalar
135
+ observable targets rendered into its scope. A default component dependency
136
+ carries only 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
data/docs/dashboard.md ADDED
@@ -0,0 +1,200 @@
1
+ # Operator dashboard
2
+
3
+ `SolidObjects::Web` is a Rack application that shows what the actor runtime is
4
+ doing: instances and their state, the mailbox, reminders, effects, broadcasts,
5
+ dead letters, and the registered processes. It reads the same tables the
6
+ runtime writes, so it needs no separate store and no agent.
7
+
8
+ It is deliberately not loaded by `require "solid_objects"`. A worker process
9
+ must not carry a web stack, and an application that never mounts the dashboard
10
+ must not pay for it.
11
+
12
+ ## Mounting
13
+
14
+ ```ruby
15
+ # config/routes.rb
16
+ require "solid_objects/web"
17
+
18
+ Rails.application.routes.draw do
19
+ mount SolidObjects::Web => "/solid_objects/dashboard"
20
+ end
21
+ ```
22
+
23
+ Mount it inside the application routes so the Rails session middleware runs
24
+ first. The dashboard needs a Rack session for CSRF protection and refuses a
25
+ state changing request without one.
26
+
27
+ The dashboard and the engine are separate mounts. Mount the engine as well if
28
+ the application uses reactive ERB, and give each one its own path:
29
+
30
+ ```ruby
31
+ mount SolidObjects::Engine => "/solid_objects"
32
+ mount SolidObjects::Web => "/solid_objects/dashboard"
33
+ ```
34
+
35
+ In a bare Rack application, supply the session middleware yourself:
36
+
37
+ ```ruby
38
+ use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: true
39
+ run SolidObjects::Web
40
+ ```
41
+
42
+ ## Authorization
43
+
44
+ Every page asks `configuration.authorize_administration` before its handler
45
+ runs. That policy denies by default, so a mount alone exposes nothing. A route
46
+ declared without a policy raises at load time, which is why a new page cannot
47
+ reach the actor tables before an application has said who may read it.
48
+
49
+ The block receives the route's own action and resource:
50
+
51
+ | Page | `action` | `resource` | `resource_id` |
52
+ | --- | --- | --- | --- |
53
+ | Dashboard, `GET /stats`, `HEAD /` | `index` | `dashboard` | none |
54
+ | Instance list | `index` | `instances` | none |
55
+ | Instance detail | `show` | `instances` | instance id |
56
+ | Pause an instance | `pause` | `instances` | instance id |
57
+ | Resume an instance | `resume` | `instances` | instance id |
58
+ | Mailbox | `index` | `messages` | none |
59
+ | Message detail | `show` | `messages` | message id |
60
+ | Reminders | `index` | `reminders` | none |
61
+ | Effects | `index` | `effects` | none |
62
+ | Broadcasts | `index` | `broadcasts` | none |
63
+ | Dead letter list | `index` | `dead_letters` | none |
64
+ | Dead letter detail | `show` | `dead_letters` | dead letter id |
65
+ | Retry a dead letter | `retry` | `dead_letters` | dead letter id |
66
+ | Processes | `index` | `processes` | none |
67
+
68
+ `authorization_context:` is the request object. It answers `request`,
69
+ `session`, and `env`, so a policy can read the signed-in operator the same way
70
+ a controller does:
71
+
72
+ ```ruby
73
+ SolidObjects.configure do |configuration|
74
+ configuration.authorize_administration = lambda do |action:, authorization_context:, **|
75
+ return false unless authorization_context.respond_to?(:session)
76
+
77
+ operator = Operator.find_by(id: authorization_context.session[:operator_id])
78
+ return false unless operator&.administrator?
79
+
80
+ action == "index" || action == "show" || operator.may_write_runtime?
81
+ end
82
+ end
83
+ ```
84
+
85
+ The command line reaches the same policy with `{ source: "cli" }` rather than
86
+ a request, which is why the example checks what the context answers before
87
+ reading a session from it.
88
+
89
+ ## Pages
90
+
91
+ **Dashboard.** Totals per subsystem, the registered processes, and the most
92
+ recent dead letters. The summary bar appears on every page and can poll
93
+ `GET /stats` for the same numbers; nothing else on the page refreshes, because
94
+ a table that reloads under an operator who is reading it is worse than a stale
95
+ one.
96
+
97
+ **Instances.** Filter by actor type and by an actor id substring. Each row
98
+ shows the lease state: `idle`, `activated`, `expired`, or `paused`. The detail
99
+ page shows committed state, the ready and claimed mailbox, message history,
100
+ reminders, effects, broadcasts, and dead letters for that identity.
101
+
102
+ **Mailbox.** The ready and claimed messages across every identity, oldest
103
+ first. Mailbox lag on the summary bar is the age of the oldest message that is
104
+ already due, which is how far behind the workers are.
105
+
106
+ **Reminders, effects, broadcasts, processes.** Status filtered lists.
107
+
108
+ ## Charts
109
+
110
+ The dashboard draws three charts: instances per actor type, mailbox depth, and
111
+ a stacked view of effects, broadcasts, and reminders by status. Each canvas
112
+ carries its own numbers in a `data-chart-values` attribute, so the page needs
113
+ no inline script and no request to draw. Mailbox depth and the status chart
114
+ redraw when the Live poller reports new totals, because `/stats` already
115
+ carries those numbers. The instance chart does not: `/stats` does not group by
116
+ actor type, and adding that would put a `GROUP BY` on every poll.
117
+
118
+ Chart.js comes from a CDN with a subresource integrity hash, so a compromised
119
+ CDN cannot substitute other code, and the CDN host is the only external origin
120
+ the content security policy names.
121
+
122
+ A deployment with no outbound network access should vendor the file:
123
+
124
+ ```ruby
125
+ SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js"
126
+ SolidObjects::Web.chart_library_integrity = nil
127
+ ```
128
+
129
+ A path below the mount is served from the dashboard's own asset directory and
130
+ needs no policy exception. Setting the URL to `nil` renders the dashboard
131
+ without charts and names no external origin at all.
132
+
133
+ Set these before the first request. The middleware stack and the compiled
134
+ templates are built once and cached.
135
+
136
+ **Dead letters.** The exception, its message, and its backtrace, with a retry
137
+ button.
138
+
139
+ ## Actions
140
+
141
+ The dashboard changes only two things.
142
+
143
+ **Retry a dead letter** goes through `SolidObjects.dead_letters.retry`, which
144
+ enqueues the original operation under an idempotency key. Pressing it twice
145
+ produces one message rather than two.
146
+
147
+ A retry re-enters the mailbox, which refuses work the runtime cannot accept: an
148
+ actor class that no longer exists, a full mailbox, a payload over the cap. The
149
+ dashboard renders the dead letter again with the reason and a 422 status,
150
+ rather than failing the request.
151
+
152
+ **Pause an instance** sets `paused_at`, and the activation manager stops
153
+ claiming that identity. Two consequences matter:
154
+
155
+ - A pass already in flight finishes its turn. Pause is not a stop.
156
+ - A synchronous caller waiting on a paused instance times out rather than
157
+ receiving a result, because nothing will execute its message.
158
+
159
+ Resume clears the column and the mailbox drains in sequence order.
160
+
161
+ ## Extensions
162
+
163
+ An extension adds pages by declaring routes on the application class. Its
164
+ routes carry an authorization policy like every other route:
165
+
166
+ ```ruby
167
+ module Tenants
168
+ def self.registered(application)
169
+ application.get "/tenants", policy: { action: "index", resource: "tenants" } do
170
+ @tenants = Tenant.order(:name)
171
+ erb(:tenants)
172
+ end
173
+ end
174
+ end
175
+
176
+ SolidObjects::Web.register(
177
+ Tenants,
178
+ tab: "Tenants",
179
+ path: "/tenants",
180
+ views: File.expand_path("../web/views", __dir__)
181
+ )
182
+ ```
183
+
184
+ A registered view directory is searched before the packaged one, so an
185
+ application can replace a single page without forking the gem. A template
186
+ reads its arguments from `locals`, and a replacement `layout.erb` renders the
187
+ page it wraps with `locals.fetch(:content)`.
188
+
189
+ Add Rack middleware in front of the dashboard with `SolidObjects::Web.use`,
190
+ for example to require HTTP basic authentication in an environment that has no
191
+ session-backed operator.
192
+
193
+ ## Cost
194
+
195
+ The summary bar issues one grouped count per subsystem on every page, and each
196
+ list page counts its own relation to page it. That is a fixed set of indexed
197
+ aggregate queries, not a scan proportional to actor traffic, but it is not
198
+ free: do not put the dashboard behind an uptime monitor that loads the whole
199
+ page on an interval. `HEAD /` exists for that. It touches one table and
200
+ returns no body.
@@ -92,10 +92,11 @@ Status/availability/ID drives delivery; completion/ID drives cleanup.
92
92
  ### `broadcasts`
93
93
 
94
94
  Durable observable-change outbox. The unique message/observable key prevents
95
- duplicate rows for one actor turn. Rows contain the observable JSON value, or
96
- `{}` for an invalidation-only observable, plus message/instance references used to derive invalidation metadata, never
97
- personalized rendered HTML. Claim and delivery indexes support retries and
98
- cleanup.
95
+ duplicate rows for one actor turn. Rows contain `{}` for the default
96
+ invalidation-only observable, or the observable JSON value after an explicit
97
+ `broadcast: :value` opt-in, plus message/instance references used to derive
98
+ invalidation metadata, never personalized rendered HTML. Claim and delivery
99
+ indexes support retries and cleanup.
99
100
 
100
101
  ### `dead_letters`
101
102
 
data/docs/realtime.md CHANGED
@@ -13,12 +13,19 @@
13
13
  <% end %>
14
14
  ```
15
15
 
16
- Every observable gets a stable opaque DOM ID. Multiple values share the one
17
- actor subscription and Action Cable multiplexes actor subscriptions over the
18
- browser's physical WebSocket.
16
+ Every value-broadcast observable gets a stable opaque DOM ID. Multiple values
17
+ share the one actor subscription and Action Cable multiplexes actor
18
+ subscriptions over the browser's physical WebSocket.
19
19
 
20
20
  Scalar observable calls such as `cart.items_count` render stable `<span>`
21
- targets. Their broadcast remains a direct escaped text replacement.
21
+ targets. Their broadcast remains a direct escaped text replacement, and their
22
+ declaration must explicitly opt in with `broadcast: :value`:
23
+
24
+ ```ruby
25
+ observable :items_count, broadcast: :value do
26
+ items.sum { |item| item.fetch("quantity") }
27
+ end
28
+ ```
22
29
 
23
30
  A reactive component declares one or more explicit observable dependencies.
24
31
  `actor.component(:summary, observes: ...)` resolves the host partial by
@@ -65,16 +72,12 @@ listed in `observes:` raises `UnknownComponentDependency`. This keeps
65
72
  invalidation correct and prevents a partial from silently depending on state
66
73
  that cannot wake it.
67
74
 
68
- Observable values are shared projections. By default, each changed value is
69
- stored in the broadcast outbox and can be sent as a scalar Turbo replacement to
70
- every subscriber that passes `authorize_subscription`. Authorization to the
71
- actor stream is not a per-viewer projection.
72
-
73
- For a component dependency whose value must never enter the durable outbox or
74
- Action Cable frame, declare it invalidation-only:
75
+ Observables are invalidation-only by default. Their values never enter the
76
+ durable outbox or Action Cable frame, so the ordinary declaration is the safe
77
+ choice for component dependencies:
75
78
 
76
79
  ```ruby
77
- observable :player_one, broadcast: :invalidation do
80
+ observable :player_one do
78
81
  player_in_seat(1)
79
82
  end
80
83
  ```
@@ -86,6 +89,10 @@ scalar value such as `actor.player_one`. The component endpoint reads the
86
89
  latest committed value and authorizes it again; subscriber-specific state
87
90
  belongs in `broadcast_payload`.
88
91
 
92
+ Use `broadcast: :value` only for a shared projection that may be stored and
93
+ sent to every subscriber that passes `authorize_subscription`. Authorization
94
+ to the actor stream is not a per-viewer projection.
95
+
89
96
  ```erb
90
97
  <ul>
91
98
  <% actor.recent_messages.each do |message| %>
@@ -437,8 +444,9 @@ component key, locals, or DOM ID.
437
444
  ## Broadcast durability
438
445
 
439
446
  The actor's fenced commit compares observables before and after the turn and
440
- inserts one broadcast row per changed observable. Value-broadcast observables
441
- store the changed JSON value; invalidation-only observables store `{}`. The actor state, monotonic
447
+ inserts one broadcast row per changed observable. Invalidation-only
448
+ observables, the default, store `{}`. Observables declared with
449
+ `broadcast: :value` store the changed JSON value. The actor state, monotonic
442
450
  `state_revision`, message completion, and broadcast rows commit atomically. A
443
451
  rolled-back or fenced-out turn therefore cannot invalidate a component.
444
452
 
data/docs/roadmap.md CHANGED
@@ -24,10 +24,11 @@
24
24
  listed here while broken in that worker: the scheduler reached a constant the
25
25
  caller path happened to load, so reminders never fired in production and
26
26
  every in-process test still passed
27
- - Durable value or invalidation-only observable broadcasts, scalar Turbo
28
- replacement, keyed ERB components, signed component locals, and authorized
29
- replace or morph refresh. Invalidation-only observables retain component
30
- change detection while storing and broadcasting no projected value
27
+ - Durable invalidation-only observable broadcasts by default, explicit
28
+ `broadcast: :value` scalar Turbo replacement, keyed ERB components, signed
29
+ component locals, and authorized replace or morph refresh. Default
30
+ observables retain component change detection while storing and broadcasting
31
+ no projected value
31
32
  - Batched component refreshes: components sharing a signed `batch:` collapse to
32
33
  one browser request per revision, served as HTML frames in a JSON envelope
33
34
  - Personalized state payload broadcasts computed per subscriber under that
@@ -107,8 +108,25 @@
107
108
  handing the block a raw Cable connection.
108
109
  - Backpressure: mailbox/payload/state/result caps and fair yields exist;
109
110
  distributed per-actor rate limits and global admission control do not.
110
- - Administration: actor and dead-letter views plus policy hooks exist; richer
111
- filtering, audit records, and bulk-safe tools do not.
111
+ - Administration: `SolidObjects::Web` is a mountable Rack dashboard covering
112
+ instances, mailbox, reminders, effects, broadcasts, dead letters, and
113
+ processes, with actor-type and actor-id filtering, status filters, paging, a
114
+ polled stats endpoint, Chart.js charts, and extension registration. The chart
115
+ library is fetched from a CDN with a subresource integrity hash, which a
116
+ deployment without outbound network access must replace with a vendored copy
117
+ or turn off. Every route declares its
118
+ own administration policy and a route declared without one raises at load
119
+ time, so the deny-by-default posture is enforced by construction rather than
120
+ by remembering to add a check. It changes only two things: an idempotent dead
121
+ letter retry and instance pause/resume. What does not exist is audit records
122
+ of who pressed what, and bulk-safe tools: retry is one dead letter at a time,
123
+ because `DeadLetterManager` exposes no bulk operation. Pause is an operator
124
+ brake and not a stop, since a pass already in flight finishes its turn and a
125
+ synchronous caller waiting on a paused instance times out. The page cost was
126
+ reasoned about rather than measured: the summary bar issues a fixed set of
127
+ indexed aggregate queries per page, which is why `HEAD /` exists for uptime
128
+ monitors, but no dashboard latency has been benchmarked against a large
129
+ table.
112
130
 
113
131
  ## Next milestones
114
132
 
data/docs/security.md CHANGED
@@ -36,13 +36,13 @@ Opaque stream and DOM names reduce accidental disclosure but do not replace
36
36
  authorization. Signed stream tokens are readable by their recipient and prove
37
37
  integrity only.
38
38
 
39
- Every normal observable value is stored in the broadcast outbox and can reach
40
- every subscriber that passes `authorize_subscription` for the actor. Never put
41
- credentials, session identifiers, private cards, hidden library order, or any
42
- other subscriber-specific state in a value-broadcast observable. Declare a
43
- component dependency with `broadcast: :invalidation` when only change metadata
44
- may cross the shared stream, or use `broadcast_payload` for a projection that
45
- must be computed separately for each authorized connection.
39
+ Observables are invalidation-only by default, so their durable rows and shared
40
+ actor stream carry no projected value. `broadcast: :value` deliberately stores
41
+ the value in the broadcast outbox and may send it to every subscriber that
42
+ passes `authorize_subscription` for the actor. Never opt credentials, session
43
+ identifiers, private cards, hidden library order, or any other
44
+ subscriber-specific state into value broadcasting. Use `broadcast_payload` for
45
+ a projection that must be computed separately for each authorized connection.
46
46
 
47
47
  ## Serialization
48
48
 
@@ -10,8 +10,8 @@ key. The chat actor also gives every submitted chat message a caller-generated
10
10
  message ID and checks that ID in durable actor state, because actor handlers may
11
11
  be redelivered.
12
12
 
13
- The views demonstrate scalar observable replacement and a live chat-message
14
- ERB component. The chat component receives `recent_messages` as an ordinary
15
- Ruby array, rerenders its `<ol>` after committed changes, and refreshes through
16
- the authenticated host request context. Turbo append intents remain roadmap
17
- work.
13
+ The views demonstrate explicit `broadcast: :value` scalar replacement and a
14
+ default invalidation-only chat-message ERB component. The chat component
15
+ receives `recent_messages` as an ordinary Ruby array, rerenders its `<ol>` after
16
+ committed changes, and refreshes through the authenticated host request
17
+ context. Turbo append intents remain roadmap work.
@@ -6,7 +6,7 @@ class ChatRoomActor < SolidObjects::Actor
6
6
  attribute :members, default: -> { [] }
7
7
  attribute :recent_messages, default: -> { [] }
8
8
 
9
- observable :presence do
9
+ observable :presence, broadcast: :value do
10
10
  members.length
11
11
  end
12
12
 
@@ -5,11 +5,11 @@ class ShoppingCartActor < SolidObjects::Actor
5
5
  attribute :checkout_status, default: "open"
6
6
  attribute :payment_id
7
7
 
8
- observable :items_count do
8
+ observable :items_count, broadcast: :value do
9
9
  items.sum { |item| item.fetch("quantity") }
10
10
  end
11
11
 
12
- observable :subtotal_cents do
12
+ observable :subtotal_cents, broadcast: :value do
13
13
  items.sum do |item|
14
14
  item.fetch("quantity") * item.fetch("unit_price_cents")
15
15
  end
@@ -53,7 +53,7 @@ module SolidObjects
53
53
  end
54
54
 
55
55
  # @rbs (Symbol | String, ?broadcast: Symbol) ?{ () -> untyped } -> ActorDefinition::Handler
56
- def observable(name, broadcast: :value, &block)
56
+ def observable(name, broadcast: :invalidation, &block)
57
57
  definition.add_observable(name, block, broadcast:)
58
58
  end
59
59