solid_objects 0.12.0 → 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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +72 -6
  4. data/app/models/solid_objects/broadcast.rb +8 -0
  5. data/docs/adr/0009-realtime-updates.md +5 -1
  6. data/docs/architecture.md +4 -2
  7. data/docs/authorization.md +11 -4
  8. data/docs/correctness.md +3 -3
  9. data/docs/dashboard.md +200 -0
  10. data/docs/database-schema.md +5 -4
  11. data/docs/development.md +12 -0
  12. data/docs/realtime.md +37 -7
  13. data/docs/roadmap.md +28 -6
  14. data/docs/security.md +8 -0
  15. data/examples/application/README.md +5 -5
  16. data/examples/application/app/actors/chat_room_actor.rb +1 -1
  17. data/examples/application/app/actors/shopping_cart_actor.rb +5 -5
  18. data/lib/solid_objects/actor.rb +6 -5
  19. data/lib/solid_objects/actor_channel.rb +7 -4
  20. data/lib/solid_objects/actor_definition.rb +15 -3
  21. data/lib/solid_objects/actor_view.rb +6 -1
  22. data/lib/solid_objects/effect_executor.rb +10 -2
  23. data/lib/solid_objects/errors.rb +3 -0
  24. data/lib/solid_objects/executor.rb +7 -1
  25. data/lib/solid_objects/reminder_scheduler.rb +28 -14
  26. data/lib/solid_objects/test_helper.rb +16 -0
  27. data/lib/solid_objects/turbo_stream_renderer.rb +3 -3
  28. data/lib/solid_objects/version.rb +1 -1
  29. data/lib/solid_objects/web/action.rb +109 -0
  30. data/lib/solid_objects/web/application.rb +239 -0
  31. data/lib/solid_objects/web/csrf_protection.rb +130 -0
  32. data/lib/solid_objects/web/helpers.rb +227 -0
  33. data/lib/solid_objects/web/paginator.rb +64 -0
  34. data/lib/solid_objects/web/route.rb +55 -0
  35. data/lib/solid_objects/web/router.rb +46 -0
  36. data/lib/solid_objects/web/statistics.rb +78 -0
  37. data/lib/solid_objects/web.rb +222 -0
  38. data/sig/generated/lib/solid_objects/actor.rbs +2 -2
  39. data/sig/generated/lib/solid_objects/actor_definition.rbs +9 -2
  40. data/sig/generated/lib/solid_objects/errors.rbs +3 -0
  41. data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +9 -6
  42. data/sig/generated/lib/solid_objects/test_helper.rbs +3 -0
  43. data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
  44. data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
  45. data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
  46. data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
  47. data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
  48. data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
  49. data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
  50. data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
  51. data/sig/generated/lib/solid_objects/web.rbs +129 -0
  52. data/sig/generated/models/solid_objects/broadcast.rbs +2 -0
  53. data/web/assets/javascripts/application.js +118 -0
  54. data/web/assets/javascripts/charts.js +190 -0
  55. data/web/assets/stylesheets/application.css +527 -0
  56. data/web/views/_messages.erb +29 -0
  57. data/web/views/_navigation.erb +15 -0
  58. data/web/views/_paging.erb +17 -0
  59. data/web/views/_status_filter.erb +8 -0
  60. data/web/views/_summary.erb +39 -0
  61. data/web/views/broadcasts.erb +35 -0
  62. data/web/views/dashboard.erb +128 -0
  63. data/web/views/dead_letter.erb +40 -0
  64. data/web/views/dead_letters.erb +42 -0
  65. data/web/views/effects.erb +35 -0
  66. data/web/views/instance.erb +139 -0
  67. data/web/views/instances.erb +49 -0
  68. data/web/views/layout.erb +22 -0
  69. data/web/views/mailbox.erb +35 -0
  70. data/web/views/message.erb +34 -0
  71. data/web/views/processes.erb +37 -0
  72. data/web/views/reminders.erb +37 -0
  73. metadata +55 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15ee987ca3dcf3419f28902e2a0c18c8444c834964b743f3e7b99cd5f6dcde33
4
- data.tar.gz: 45bf4d0449184c9fbf3718ce088f8541b68049e9458ee8802947fd74bef1fadb
3
+ metadata.gz: 8dc01a4c7b7f44d7dbd68b1194abd64a2c5515d5280a1e9cae15b119ff18508c
4
+ data.tar.gz: 68099ea645dff1d976f9aeda77d79d04ed8280e18bfc6d4235daca972ffafad4
5
5
  SHA512:
6
- metadata.gz: 6b5b29a7f3052c1a04a012f348a9f8f1054500056073b34c8b2a6680f7d84db195efbe0d5d68809b33ba7bed04af1c141777a27801e6049df0863311cd948a25
7
- data.tar.gz: 93e7bdeb0ee3a1a0c4f212f5da40c25d61bca88b1ea2dc87efb4b09d199068b5374452beb991baec05b573ac3e0109ca86dde37463a7cf5f28d823f02969767a
6
+ metadata.gz: 335cec6b8f87328e9a88eee32bbd8aaca5b019cb0ef76dfa70f5efa261afb81a2a034d3bc6a11189f22c4809583d9cce1538c6ecb1ff8cc8319c1d195fec8295
7
+ data.tar.gz: 445c227d5badd737cb44f9b44b87917b73c3e2a55a66acb6a4cd1fe09a39febda314f00188cd35eabc70acef5d5b95503c3cedf5c35632e22948fa62667a62db
data/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
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
+
40
+ ## 0.12.1 - 2026-08-13
41
+
42
+ - Add invalidation-only observables with `broadcast: :invalidation`. They still
43
+ detect changes and refresh reactive components, but persist `{}` and send no
44
+ scalar value over Action Cable. Document that ordinary observable values are
45
+ shared with every authorized actor subscriber and that subscriber-specific
46
+ state belongs in a payload projection.
47
+ - **Breaking:** include the originally staged `arguments:` in effect success
48
+ and failure callbacks so actors can correlate concurrent effects.
49
+ - Accept identifier-style rejection codes, including camelCase and symbols,
50
+ and fail malformed codes once with non-retryable
51
+ `SolidObjects::InvalidRejectionCode` diagnostics.
52
+ - Add `run_due_reminders(now:)` to `SolidObjects::TestHelper` for deterministic
53
+ reminder tests without sleeping or mutating runtime rows.
54
+
3
55
  ## 0.12.0 - 2026-08-13
4
56
 
5
57
  - Replace positional actor dispatch with fluent operation selection. Direct
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,6 +206,23 @@ dependencies changes:
205
206
  <% end %>
206
207
  ```
207
208
 
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:
213
+
214
+ ```ruby
215
+ observable :message_count, broadcast: :value do
216
+ recent_messages.length
217
+ end
218
+ ```
219
+
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.
225
+
208
226
  Component names can repeat when each instance has a stable key. Signed
209
227
  JSON-compatible locals let one conventional partial render the matching
210
228
  projection:
@@ -515,7 +533,7 @@ class ShoppingCart < SolidObjects::Actor
515
533
  end
516
534
  end
517
535
 
518
- observable :items_count do
536
+ observable :items_count, broadcast: :value do
519
537
  items.sum { |item| item.fetch("quantity") }
520
538
  end
521
539
  end
@@ -736,8 +754,8 @@ JSON-compatible details. The rejected message remains durable for audit, actor
736
754
  state is rolled back, and no later mailbox turn is blocked.
737
755
 
738
756
  `Rejected#code` is a `String`, even when `reject` receives a symbol. Codes must
739
- match `\A[a-z][a-z0-9_]*\z`; invalid codes raise `ArgumentError` when the
740
- handler calls `reject`.
757
+ match `\A[A-Za-z_][A-Za-z0-9_]*\z`. Invalid codes raise
758
+ `SolidObjects::InvalidRejectionCode` and fail the turn without retrying.
741
759
 
742
760
  ### Redelivery
743
761
 
@@ -819,11 +837,11 @@ def checkout(payment_id:, amount_cents:)
819
837
  )
820
838
  end
821
839
 
822
- def payment_succeeded(effect_id:, result:)
840
+ def payment_succeeded(effect_id:, arguments:, result:)
823
841
  self.checkout_status = "paid"
824
842
  end
825
843
 
826
- def payment_failed(effect_id:, error:)
844
+ def payment_failed(effect_id:, arguments:, error:)
827
845
  self.checkout_status = "failed"
828
846
  end
829
847
  ```
@@ -842,6 +860,10 @@ end
842
860
 
843
861
  The provider call can repeat if a process dies after external success but
844
862
  before recording completion. The stable effect ID is the idempotency key.
863
+ Success callbacks receive `effect_id:`, the originally staged `arguments:`,
864
+ and `result:`. Failure callbacks receive `effect_id:`, `arguments:`, and
865
+ `error:`, so an actor can correlate concurrent effects without storing a
866
+ separate callback ledger.
845
867
 
846
868
  ## Reminders
847
869
 
@@ -1063,6 +1085,50 @@ an initializer.
1063
1085
  See the [operations guide](docs/operations.md) for monitoring, reconciliation,
1064
1086
  shutdown, retention, and backup guidance.
1065
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
+
1066
1132
  ## Database support
1067
1133
 
1068
1134
  Solid Objects supports:
@@ -6,5 +6,13 @@ module SolidObjects
6
6
 
7
7
  belongs_to :message, class_name: "SolidObjects::Message"
8
8
  belongs_to :instance, class_name: "SolidObjects::Instance"
9
+
10
+ # @rbs () -> bool
11
+ def broadcasts_value?
12
+ return false if observable_name == PayloadBroadcast::REVISION_OBSERVABLE
13
+
14
+ actor_class = SolidObjects.registry.fetch(instance.actor_type)
15
+ actor_class.definition.broadcasts_observable_value?(observable_name)
16
+ end
9
17
  end
10
18
  end
@@ -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
@@ -113,7 +113,7 @@ The supervisor starts configured worker, effect, reminder, and broadcast thread
113
113
 
114
114
  ### Effect worker
115
115
 
116
- An effect worker claims due effect rows through the database coordination adapter, invokes a registered handler outside a database transaction, then records success or retryable failure. The handler receives the effect UUID as its idempotency key. Optional outcome messages are normal actor mailbox messages.
116
+ An effect worker claims due effect rows through the database coordination adapter, invokes a registered handler outside a database transaction, then records success or retryable failure. The handler receives the effect UUID as its idempotency key. Optional outcome messages are normal actor mailbox messages and receive the effect ID, originally staged arguments, and result or error.
117
117
 
118
118
  ### Reminder scheduler
119
119
 
@@ -169,7 +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; it is not independently persisted.
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.
173
175
 
174
176
  Lifecycle hooks are deterministic local hooks:
175
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 and
96
- 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/development.md CHANGED
@@ -65,6 +65,18 @@ assert_equal "completed", message.status
65
65
  Pass `roles: [:actors]` when a test intentionally wants to leave outboxes or
66
66
  reminders pending.
67
67
 
68
+ Rails time travel does not move the database clock used by reminder claims. Run
69
+ future reminders against an explicit test instant instead of updating runtime
70
+ rows or sleeping:
71
+
72
+ ```ruby
73
+ assert_equal 1, run_due_reminders(now: 5.minutes.from_now)
74
+ assert_equal 1, drain_solid_objects(roles: [ :actors ])
75
+ ```
76
+
77
+ The explicit instant controls due selection and recurring schedule advancement.
78
+ Claim timestamps and stale-process recovery still use database time.
79
+
68
80
  `SolidObjects::TestHelper.reset_actors!` is also available for explicit suite
69
81
  boundaries. It deletes every actor-owned row itself rather than deleting actor
70
82
  instances and letting the database cascade remove the rest: SQLite has to be
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,6 +72,27 @@ 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
 
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:
78
+
79
+ ```ruby
80
+ observable :player_one do
81
+ player_in_seat(1)
82
+ end
83
+ ```
84
+
85
+ The runtime still compares the value around each successful turn and uses a
86
+ change to refresh components, but stores `{}` and renders no scalar Turbo
87
+ replacement. An invalidation-only observable therefore cannot be used as a
88
+ scalar value such as `actor.player_one`. The component endpoint reads the
89
+ latest committed value and authorizes it again; subscriber-specific state
90
+ belongs in `broadcast_payload`.
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
+
68
96
  ```erb
69
97
  <ul>
70
98
  <% actor.recent_messages.each do |message| %>
@@ -416,7 +444,9 @@ component key, locals, or DOM ID.
416
444
  ## Broadcast durability
417
445
 
418
446
  The actor's fenced commit compares observables before and after the turn and
419
- inserts one broadcast row per changed value. 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
420
450
  `state_revision`, message completion, and broadcast rows commit atomically. A
421
451
  rolled-back or fenced-out turn therefore cannot invalidate a component.
422
452
 
@@ -456,8 +486,8 @@ response revision fencing.
456
486
  ## Cost model
457
487
 
458
488
  The durable row cost is unchanged: one broadcast row per changed observable,
459
- containing its JSON value and the message/instance references needed to derive
460
- invalidation metadata. No rendered document is stored. Each affected component
489
+ containing either its JSON value or an empty invalidation marker plus the
490
+ message/instance references needed to derive invalidation metadata. No rendered document is stored. Each affected component
461
491
  adds one authorized GET and one partial render per non-coalesced state
462
492
  revision. A repeated keyed component adds one GET and render per key. Signed
463
493
  locals increase page and Cable subscription bytes but do not create durable