solid_objects 0.12.1 → 0.13.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +69 -13
  4. data/benchmark/idle_polling.rb +98 -0
  5. data/benchmark/support.rb +2 -0
  6. data/docs/adr/0009-realtime-updates.md +5 -1
  7. data/docs/adr/0011-wake-up-strategy.md +6 -0
  8. data/docs/architecture.md +5 -4
  9. data/docs/authorization.md +11 -4
  10. data/docs/benchmarks.md +38 -0
  11. data/docs/correctness.md +3 -3
  12. data/docs/dashboard.md +200 -0
  13. data/docs/database-schema.md +5 -4
  14. data/docs/development.md +1 -0
  15. data/docs/operations.md +24 -0
  16. data/docs/realtime.md +22 -14
  17. data/docs/roadmap.md +32 -11
  18. data/docs/security.md +7 -7
  19. data/examples/application/README.md +5 -5
  20. data/examples/application/app/actors/chat_room_actor.rb +1 -1
  21. data/examples/application/app/actors/shopping_cart_actor.rb +2 -2
  22. data/lib/solid_objects/actor.rb +1 -1
  23. data/lib/solid_objects/broadcast_executor.rb +35 -4
  24. data/lib/solid_objects/configuration.rb +4 -0
  25. data/lib/solid_objects/effect_executor.rb +34 -3
  26. data/lib/solid_objects/polling_backoff.rb +45 -0
  27. data/lib/solid_objects/process_registry.rb +51 -0
  28. data/lib/solid_objects/reminder_scheduler.rb +35 -4
  29. data/lib/solid_objects/version.rb +1 -1
  30. data/lib/solid_objects/wake_up.rb +36 -4
  31. data/lib/solid_objects/wake_up_adapters/postgresql.rb +6 -0
  32. data/lib/solid_objects/wake_up_adapters/redis.rb +25 -3
  33. data/lib/solid_objects/web/action.rb +109 -0
  34. data/lib/solid_objects/web/application.rb +239 -0
  35. data/lib/solid_objects/web/csrf_protection.rb +130 -0
  36. data/lib/solid_objects/web/helpers.rb +227 -0
  37. data/lib/solid_objects/web/paginator.rb +64 -0
  38. data/lib/solid_objects/web/route.rb +55 -0
  39. data/lib/solid_objects/web/router.rb +46 -0
  40. data/lib/solid_objects/web/statistics.rb +78 -0
  41. data/lib/solid_objects/web.rb +222 -0
  42. data/lib/solid_objects/worker.rb +39 -3
  43. data/lib/solid_objects.rb +2 -0
  44. data/sig/generated/lib/solid_objects/broadcast_executor.rbs +7 -0
  45. data/sig/generated/lib/solid_objects/configuration.rbs +6 -2
  46. data/sig/generated/lib/solid_objects/effect_executor.rbs +7 -0
  47. data/sig/generated/lib/solid_objects/polling_backoff.rbs +29 -0
  48. data/sig/generated/lib/solid_objects/process_registry.rbs +15 -0
  49. data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +7 -0
  50. data/sig/generated/lib/solid_objects/wake_up.rbs +19 -2
  51. data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +3 -0
  52. data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +17 -2
  53. data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
  54. data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
  55. data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
  56. data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
  57. data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
  58. data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
  59. data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
  60. data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
  61. data/sig/generated/lib/solid_objects/web.rbs +129 -0
  62. data/sig/generated/lib/solid_objects/worker.rbs +7 -0
  63. data/web/assets/javascripts/application.js +118 -0
  64. data/web/assets/javascripts/charts.js +190 -0
  65. data/web/assets/stylesheets/application.css +527 -0
  66. data/web/views/_messages.erb +29 -0
  67. data/web/views/_navigation.erb +15 -0
  68. data/web/views/_paging.erb +17 -0
  69. data/web/views/_status_filter.erb +8 -0
  70. data/web/views/_summary.erb +39 -0
  71. data/web/views/broadcasts.erb +35 -0
  72. data/web/views/dashboard.erb +128 -0
  73. data/web/views/dead_letter.erb +40 -0
  74. data/web/views/dead_letters.erb +42 -0
  75. data/web/views/effects.erb +35 -0
  76. data/web/views/instance.erb +139 -0
  77. data/web/views/instances.erb +49 -0
  78. data/web/views/layout.erb +22 -0
  79. data/web/views/mailbox.erb +35 -0
  80. data/web/views/message.erb +34 -0
  81. data/web/views/processes.erb +37 -0
  82. data/web/views/reminders.erb +37 -0
  83. metadata +58 -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: e5bd1724baaa40d79ec3693558ef7aa8e37830e4c5afd1da1d5774356d1e584e
4
+ data.tar.gz: 1e7a2307cae51316b9b4c4f56ea6fc986a622e3665541514137d87bbf72b2e9a
5
5
  SHA512:
6
- metadata.gz: d28e8e826c045dc546a13eb34631c692281b82b10a55205f0f6b3a17b77a1a2144bbfb786c69c86d701d42d356c65da4c26d7dd2f25a5538f9743e4405aa53b1
7
- data.tar.gz: 34c89447e1592ea056d511db1fbf01de898c1563c34616a7b80058b0c1bb649b3a4e01cf7f1fad6de35bedc82e484c0a8c96b21aa0be1d959b94c3492fd3d3c4
6
+ metadata.gz: b1b5284ef616f306bea2136d7becf4a3c8e4aa2adbcc24eb5da08c3f33e85f34551f687ff7133cd3781abba79d4f0dac919657d9150bfe3ebd021417542d505b
7
+ data.tar.gz: 6971c0ee78d9eab66776a5ba6d60dcc403d3346c57cffca795797f50048fc419b6473d3d620c4d025238916757f52ed79ed1ec153feaa3dda84744d740b08045
data/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.1 - 2026-08-16
4
+
5
+ - Back idle actor, effect, reminder, and broadcast polling off exponentially
6
+ from the configured fast interval to a new one-second idle ceiling. Any
7
+ processed work or wake-up resets the role immediately, and actor polling
8
+ remains capped by the lease-renewal interval.
9
+ - Expose each role's `current_polling_interval` and emit
10
+ `solid_objects.polling.interval_changed` instrumentation for every idle,
11
+ work, and wake-up transition.
12
+ - Warn once when live processes share the database without a configured
13
+ cross-process wake-up adapter.
14
+ - Make the in-process wake-up generation-aware so a signal committed between
15
+ an empty claim and the wait is not missed. PostgreSQL and Redis adapters now
16
+ expose the same watch contract.
17
+ - Add a reproducible four-role SQLite idle benchmark and repair the benchmark
18
+ schema setup for the current operation columns.
19
+ - **Behavior change:** `polling_interval` is now the fast interval after
20
+ activity, not a constant idle cadence. Existing explicit values back off to
21
+ `idle_polling_interval`, which defaults to one second. Set both options to
22
+ the same value to preserve a fixed cadence. Existing custom wake-up adapters
23
+ that return `nil` remain at the fast cadence until they return `false` for a
24
+ timeout and `true` for a notification.
25
+
26
+ ## 0.13.0 - 2026-08-15
27
+
28
+ - **Breaking:** make observables invalidation-only by default. An ordinary
29
+ `observable :status` continues to detect changes and refresh reactive
30
+ components, but persists `{}` and sends no scalar value over Action Cable.
31
+ Declare `observable :status, broadcast: :value` to deliberately store and
32
+ share the projection with every authorized actor subscriber. Applications
33
+ upgrading from 0.12.x must add that opt-in to observables rendered as scalar
34
+ targets.
35
+ - Add `SolidObjects::Web`, a mountable Rack dashboard for the actor runtime.
36
+ It covers instances and their committed state, the ready and claimed
37
+ mailbox, reminders, effects, broadcasts, dead letters, and processes, with
38
+ actor-type and actor-id filtering, status filters, paging, and a polled
39
+ `GET /stats` endpoint. Mount it with
40
+ `mount SolidObjects::Web => "/solid_objects/dashboard"` after
41
+ `require "solid_objects/web"`; requiring the gem does not load it, so a
42
+ worker process carries no web stack.
43
+ - Authorize every dashboard route through `authorize_administration`. Each
44
+ route declares its own `action` and `resource`, and a route declared without
45
+ a policy raises at load time. The policy receives a context that answers
46
+ `request`, `session`, and `env`.
47
+ - Add two dashboard actions: an idempotent dead letter retry through
48
+ `SolidObjects.dead_letters.retry`, and instance pause/resume, which sets and
49
+ clears `paused_at` so the activation manager stops claiming that identity. A
50
+ retry the mailbox refuses, such as an actor class that no longer exists,
51
+ renders the reason with a 422 rather than failing the request.
52
+ - Draw instances per actor type, mailbox depth, and outbox and reminder status
53
+ with Chart.js, loaded from a CDN with a subresource integrity hash. The CDN
54
+ host is the only external origin the content security policy names. Point
55
+ `SolidObjects::Web.chart_library_url` at a vendored copy for a deployment
56
+ with no outbound network access, or set it to nil to render without charts.
57
+ - Add `SolidObjects::Web.register` for extension tabs, routes, and view
58
+ directories, and `SolidObjects::Web.use` for Rack middleware in front of the
59
+ dashboard.
60
+ - Add `rack` as an explicit dependency at `>= 3.1`, and package the `web/`
61
+ directory in the gem.
62
+
3
63
  ## 0.12.1 - 2026-08-13
4
64
 
5
65
  - 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
@@ -1011,6 +1014,7 @@ Important defaults:
1011
1014
  | Setting | Default |
1012
1015
  | --- | ---: |
1013
1016
  | `polling_interval` | 0.1 seconds |
1017
+ | `idle_polling_interval` | 1 second |
1014
1018
  | `sync_polling_interval` | 0.05 seconds |
1015
1019
  | `lease_duration` | 30 seconds |
1016
1020
  | `lease_renewal_interval` | 10 seconds |
@@ -1035,6 +1039,14 @@ Payload, state, and result limits; retry delay; table prefix; logging; wake-up;
1035
1039
  broadcast; database; and authorization adapters are also configurable. Invalid
1036
1040
  lease intervals, component counts, and size limits fail fast at boot.
1037
1041
 
1042
+ `polling_interval` is the fast interval after work or a wake-up. Consecutive
1043
+ empty passes double it up to `idle_polling_interval`. Actor workers never wait
1044
+ longer than `lease_renewal_interval`. Set the fast and idle values equal for a
1045
+ fixed cadence. The default wake-up reaches only the current Ruby process;
1046
+ configure PostgreSQL notifications or optional Redis Pub/Sub when separate
1047
+ processes need low-latency delivery. The runtime warns once when it sees that
1048
+ topology without an adapter.
1049
+
1038
1050
  ## Workers and operations
1039
1051
 
1040
1052
  `solid_objects start` runs actor, effect, reminder, and broadcast roles under
@@ -1082,6 +1094,50 @@ an initializer.
1082
1094
  See the [operations guide](docs/operations.md) for monitoring, reconciliation,
1083
1095
  shutdown, retention, and backup guidance.
1084
1096
 
1097
+ ## Dashboard
1098
+
1099
+ `SolidObjects::Web` is a Rack application that shows instances and their state,
1100
+ the mailbox, reminders, effects, broadcasts, dead letters, and the registered
1101
+ processes. Mount it inside the application routes, so the Rails session
1102
+ middleware runs first:
1103
+
1104
+ ```ruby
1105
+ # config/routes.rb
1106
+ require "solid_objects/web"
1107
+
1108
+ Rails.application.routes.draw do
1109
+ mount SolidObjects::Web => "/solid_objects/dashboard"
1110
+ end
1111
+ ```
1112
+
1113
+ It is not loaded by `require "solid_objects"`: a worker process must not carry
1114
+ a web stack. The dashboard and the engine are separate mounts, so an
1115
+ application that uses reactive ERB mounts both on different paths.
1116
+
1117
+ Every page asks `authorize_administration` before its handler runs, and that
1118
+ policy denies by default, so a mount alone exposes nothing. The block receives
1119
+ the route's own `action:` and `resource:`, and an `authorization_context:` that
1120
+ answers `request`, `session`, and `env`.
1121
+
1122
+ The dashboard changes only two things. Retrying a dead letter goes through
1123
+ `SolidObjects.dead_letters.retry`, which is idempotent. Pausing an instance
1124
+ sets `paused_at` so the activation manager stops claiming that identity; a pass
1125
+ already in flight finishes its turn, and a synchronous caller waiting on a
1126
+ paused instance times out rather than receiving a result.
1127
+
1128
+ The dashboard draws instances per actor type, mailbox depth, and outbox status
1129
+ with Chart.js, loaded from a CDN with a subresource integrity hash. A
1130
+ deployment with no outbound network access can vendor the file, or turn the
1131
+ charts off:
1132
+
1133
+ ```ruby
1134
+ SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js"
1135
+ SolidObjects::Web.chart_library_integrity = nil
1136
+ ```
1137
+
1138
+ Read the [dashboard guide](docs/dashboard.md) for the full policy table,
1139
+ extension registration, and query cost.
1140
+
1085
1141
  ## Database support
1086
1142
 
1087
1143
  Solid Objects supports:
@@ -1252,8 +1308,8 @@ Partially implemented:
1252
1308
 
1253
1309
  - the supervisor starts and drains roles but does not replace a crashed role or
1254
1310
  run periodic maintenance automatically;
1255
- - cross-process wake-up uses polling; PostgreSQL notifications and optional
1256
- Redis acceleration are not implemented;
1311
+ - PostgreSQL notifications and optional Redis acceleration are implemented,
1312
+ but adapter selection remains explicit and polling is the durable fallback;
1257
1313
  - live observable and component replacement work, while Turbo append actions
1258
1314
  remain future work;
1259
1315
  - local admission limits exist, but distributed rate limits and global
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rbconfig"
5
+ require_relative "support"
6
+
7
+ class CountingWakeUp < SolidObjects::WakeUp
8
+ def initialize
9
+ super
10
+ @count_mutex = Mutex.new
11
+ @poll_count = 0
12
+ end
13
+
14
+ def wait(timeout:, generation: nil)
15
+ @count_mutex.synchronize { @poll_count += 1 }
16
+ super
17
+ end
18
+
19
+ def reset_count
20
+ @count_mutex.synchronize { @poll_count = 0 }
21
+ end
22
+
23
+ def poll_count
24
+ @count_mutex.synchronize { @poll_count }
25
+ end
26
+ end
27
+
28
+ def measure(interval:, warmup:, duration:, wake_up:)
29
+ SolidObjects.configuration.polling_interval = interval
30
+ SolidObjects.configuration.idle_polling_interval = 1.0
31
+ components = [
32
+ SolidObjects::Worker.new,
33
+ SolidObjects::EffectExecutor.new,
34
+ SolidObjects::ReminderScheduler.new,
35
+ SolidObjects::BroadcastExecutor.new
36
+ ]
37
+ threads = components.map { |component| Thread.new { component.run } }
38
+
39
+ sleep warmup
40
+ wake_up.reset_count
41
+ cpu_started_at = Process.times
42
+ wall_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
43
+ sleep duration
44
+ wall_elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_started_at
45
+ cpu_finished_at = Process.times
46
+ cpu_elapsed = cpu_finished_at.utime + cpu_finished_at.stime -
47
+ cpu_started_at.utime - cpu_started_at.stime
48
+ poll_count = wake_up.poll_count
49
+
50
+ {
51
+ polling_interval: interval,
52
+ idle_polling_interval: SolidObjects.configuration.idle_polling_interval,
53
+ current_intervals: components.map(&:current_polling_interval),
54
+ polls: poll_count,
55
+ polls_per_second: (poll_count / wall_elapsed).round(3),
56
+ idle_cpu_percent: ((cpu_elapsed / wall_elapsed) * 100).round(3)
57
+ }
58
+ ensure
59
+ components&.each(&:request_shutdown)
60
+ threads&.each { |thread| thread.join(2) }
61
+ components&.each(&:stop)
62
+ end
63
+
64
+ intervals = ENV.fetch("INTERVALS", "0.02,0.1,0.5").split(",").map do |value|
65
+ Float(value).tap { |interval| raise ArgumentError, "intervals must be positive" unless interval.positive? }
66
+ end
67
+ warmup = Float(ENV.fetch("WARMUP", "3"))
68
+ duration = Float(ENV.fetch("DURATION", "10"))
69
+ raise ArgumentError, "warmup must be positive" unless warmup.positive?
70
+ raise ArgumentError, "duration must be positive" unless duration.positive?
71
+
72
+ SolidObjectsBenchmark.setup
73
+ wake_up = CountingWakeUp.new
74
+ SolidObjects.configuration.wake_up_adapter = wake_up
75
+ database_version = ActiveRecord::Base.connection.select_value("SELECT sqlite_version()")
76
+ results = intervals.map { |interval| measure(interval:, warmup:, duration:, wake_up:) }
77
+ puts JSON.pretty_generate(
78
+ measured_at: Time.now.utc.iso8601,
79
+ package_version: SolidObjects::VERSION,
80
+ runtime: {
81
+ ruby: RUBY_DESCRIPTION,
82
+ platform: RUBY_PLATFORM,
83
+ cpu: RbConfig::CONFIG.fetch("host_cpu")
84
+ },
85
+ database: {
86
+ adapter: "sqlite",
87
+ version: database_version,
88
+ path: SolidObjectsBenchmark::DATABASE_PATH
89
+ },
90
+ methodology: {
91
+ roles: %w[actors effects reminders broadcasts],
92
+ warmup_seconds: warmup,
93
+ duration_seconds: duration,
94
+ cpu_percent: "process user plus system CPU time divided by wall time"
95
+ },
96
+ results:
97
+ )
98
+ SolidObjectsBenchmark.teardown
data/benchmark/support.rb CHANGED
@@ -402,8 +402,10 @@ module SolidObjectsBenchmark
402
402
  def migrate
403
403
  require_relative "../db/migrate/20260805000000_create_solid_objects_tables"
404
404
  require_relative "../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances"
405
+ require_relative "../db/migrate/20260813000000_rename_message_dispatch_columns"
405
406
  CreateSolidObjectsTables.new.migrate(:up)
406
407
  AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
408
+ RenameMessageDispatchColumns.new.migrate(:up)
407
409
  end
408
410
 
409
411
  # @rbs () -> void
@@ -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
@@ -11,6 +11,10 @@ Polling adds latency and database queries. PostgreSQL notifications are transact
11
11
 
12
12
  Database rows remain the only durable source of work and results. Wake-up
13
13
  adapters only prompt workers and synchronous waiters to re-query those rows.
14
+ Actor, effect, reminder, and broadcast roles double consecutive empty waits
15
+ from `polling_interval` to `idle_polling_interval`. Work and notifications reset
16
+ the wait immediately. Actor workers clamp the ceiling to
17
+ `lease_renewal_interval`.
14
18
 
15
19
  The interface supports:
16
20
 
@@ -40,4 +44,6 @@ Timeout does not cancel durable work.
40
44
  - A reconnecting PostgreSQL listener must commit `LISTEN`, inspect current state, and then wait.
41
45
  - Redis loss only increases latency and never loses durable work.
42
46
  - Every adapter retains periodic polling to close startup, reconnect, and missed-message races.
47
+ - A process that returns `false` from a timed wait participates in backoff; an older custom adapter that returns `nil` keeps the fast cadence.
48
+ - A multi-process deployment without an adapter trades idle database load for up to the current idle polling interval of notification latency and logs that topology once.
43
49
  - Notification payloads never contain actor arguments or results.
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
 
@@ -364,8 +365,8 @@ it drains earlier messages and the target through the same activation and
364
365
  executor used by workers. If another process owns the actor, the caller waits
365
366
  for the row to become completed, rejected, dead-lettered, destroyed, or timed
366
367
  out. Every wait re-queries durable rows. The implemented wake-up interface
367
- provides same-process signaling, bounded polling, and dependency injection.
368
- PostgreSQL `LISTEN/NOTIFY` and optional Redis Pub/Sub are planned adapters.
368
+ provides generation-aware same-process signaling, adaptive bounded polling,
369
+ PostgreSQL `LISTEN/NOTIFY`, and optional Redis Pub/Sub.
369
370
 
370
371
  The normal path does not wait for a worker polling interval because the caller
371
372
  assists execution immediately. End-to-end latency still includes earlier
@@ -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/benchmarks.md CHANGED
@@ -5,6 +5,44 @@ They include the runtime's Active Record and database query overhead and will
5
5
  vary with hardware, schema size, connection pools, durability settings, and
6
6
  contention.
7
7
 
8
+ ## Idle SQLite polling
9
+
10
+ Run the four-role idle harness with:
11
+
12
+ ```bash
13
+ bundle exec ruby -Ilib benchmark/idle_polling.rb
14
+ ```
15
+
16
+ It warms each interval for three seconds, measures for ten seconds, and reports
17
+ process user plus system CPU time divided by wall time. Measured August 16,
18
+ 2026 on an Apple M5 with Ruby 4.0.6 and SQLite 3.53.2. The before run used
19
+ 0.13.0; the after run used the prepared 0.13.1 tree.
20
+
21
+ | Fast interval | Before polls/s | Before CPU | After polls/s | After CPU |
22
+ | ---: | ---: | ---: | ---: | ---: |
23
+ | 20 ms | 165.340 | 8.401% | 3.998 | 0.947% |
24
+ | 100 ms | 38.396 | 2.925% | 3.999 | 0.482% |
25
+ | 500 ms | 7.998 | 2.061% | 3.996 | 0.283% |
26
+
27
+ The after run reached the one-second ceiling for the actor, effect, reminder,
28
+ and broadcast roles. These are developer-laptop measurements, not a CPU
29
+ guarantee; timer scheduling, YJIT, the SQLite file, and unrelated host activity
30
+ affect short samples.
31
+
32
+ Five SQLite samples measured durable enqueue through committed completion after
33
+ 2.5 seconds of idleness. The polling-only multi-process harness submits just
34
+ after an empty pass, so it measures approximately the full polling wait rather
35
+ than average arrival latency.
36
+
37
+ | Topology | 0.13.0 p50 | Prepared 0.13.1 p50 |
38
+ | --- | ---: | ---: |
39
+ | One process, in-process wake-up | 43.360 ms | 50.339 ms |
40
+ | Two processes, polling only | 117.787 ms | 1,028.006 ms |
41
+
42
+ The local wake-up keeps the one-process path prompt after backoff. The
43
+ polling-only row is the explicit tradeoff: use PostgreSQL notifications or
44
+ optional Redis Pub/Sub when separate processes need low-latency delivery.
45
+
8
46
  ## Production-shaped adoption measurement
9
47
 
10
48
  An adoption evaluation measured Solid Objects 0.2.0 from a macOS Rails process
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