solid_objects 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +261 -31
  4. data/app/controllers/solid_objects/components_controller.rb +78 -0
  5. data/app/helpers/solid_objects/actor_helper.rb +13 -4
  6. data/config/routes.rb +1 -0
  7. data/db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb +12 -0
  8. data/docs/architecture.md +116 -23
  9. data/docs/authorization.md +53 -3
  10. data/docs/benchmarks.md +6 -2
  11. data/docs/correctness.md +45 -1
  12. data/docs/database-schema.md +18 -5
  13. data/docs/development.md +40 -0
  14. data/docs/fit.md +10 -2
  15. data/docs/migrating-existing-state.md +8 -1
  16. data/docs/operations.md +74 -36
  17. data/docs/realtime.md +106 -12
  18. data/docs/roadmap.md +14 -7
  19. data/docs/security.md +25 -3
  20. data/docs/state-migrations.md +2 -0
  21. data/examples/application/README.md +5 -4
  22. data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +1 -1
  23. data/examples/application/app/views/chat_rooms/show.html.erb +2 -2
  24. data/examples/application/config/initializers/solid_objects.rb +9 -3
  25. data/lib/generators/solid_objects/templates/solid_objects.rb +33 -2
  26. data/lib/solid_objects/activation.rb +33 -4
  27. data/lib/solid_objects/actor.rb +58 -6
  28. data/lib/solid_objects/actor_channel.rb +73 -5
  29. data/lib/solid_objects/actor_definition.rb +2 -0
  30. data/lib/solid_objects/actor_snapshot.rb +35 -10
  31. data/lib/solid_objects/actor_view.rb +103 -10
  32. data/lib/solid_objects/application_write_guard.rb +24 -0
  33. data/lib/solid_objects/caller_process.rb +28 -0
  34. data/lib/solid_objects/cli.rb +44 -5
  35. data/lib/solid_objects/client.rb +98 -5
  36. data/lib/solid_objects/commit_action_registry.rb +42 -0
  37. data/lib/solid_objects/component_path_resolver.rb +27 -0
  38. data/lib/solid_objects/component_registration.rb +117 -0
  39. data/lib/solid_objects/component_renderer.rb +82 -0
  40. data/lib/solid_objects/component_subscriptions.rb +109 -0
  41. data/lib/solid_objects/component_token.rb +139 -0
  42. data/lib/solid_objects/component_view.rb +67 -0
  43. data/lib/solid_objects/configuration.rb +39 -1
  44. data/lib/solid_objects/database_adapter.rb +28 -1
  45. data/lib/solid_objects/database_adapters/mysql.rb +46 -0
  46. data/lib/solid_objects/database_adapters/postgresql.rb +29 -0
  47. data/lib/solid_objects/database_adapters/sqlite.rb +40 -0
  48. data/lib/solid_objects/errors.rb +156 -0
  49. data/lib/solid_objects/executor.rb +65 -4
  50. data/lib/solid_objects/instance_pruner.rb +97 -0
  51. data/lib/solid_objects/message_pruner.rb +97 -0
  52. data/lib/solid_objects/message_reference.rb +9 -0
  53. data/lib/solid_objects/process_pruner.rb +49 -0
  54. data/lib/solid_objects/reference.rb +5 -0
  55. data/lib/solid_objects/state_snapshot.rb +41 -0
  56. data/lib/solid_objects/stream_token.rb +32 -13
  57. data/lib/solid_objects/sync_deadline.rb +57 -0
  58. data/lib/solid_objects/sync_diagnostics.rb +133 -0
  59. data/lib/solid_objects/synchronous_invocation.rb +26 -7
  60. data/lib/solid_objects/test_helper.rb +78 -0
  61. data/lib/solid_objects/turbo_stream_renderer.rb +45 -1
  62. data/lib/solid_objects/version.rb +1 -1
  63. data/lib/solid_objects/worker.rb +1 -1
  64. data/lib/solid_objects.rb +40 -0
  65. data/sig/generated/controllers/solid_objects/components_controller.rbs +22 -0
  66. data/sig/generated/lib/solid_objects/activation.rbs +3 -0
  67. data/sig/generated/lib/solid_objects/actor.rbs +29 -0
  68. data/sig/generated/lib/solid_objects/actor_channel.rbs +20 -0
  69. data/sig/generated/lib/solid_objects/actor_snapshot.rbs +17 -0
  70. data/sig/generated/lib/solid_objects/actor_view.rbs +31 -2
  71. data/sig/generated/lib/solid_objects/application_write_guard.rbs +8 -0
  72. data/sig/generated/lib/solid_objects/caller_process.rbs +11 -0
  73. data/sig/generated/lib/solid_objects/cli.rbs +11 -2
  74. data/sig/generated/lib/solid_objects/client.rbs +15 -0
  75. data/sig/generated/lib/solid_objects/commit_action_registry.rbs +43 -0
  76. data/sig/generated/lib/solid_objects/component_path_resolver.rbs +13 -0
  77. data/sig/generated/lib/solid_objects/component_registration.rbs +51 -0
  78. data/sig/generated/lib/solid_objects/component_renderer.rbs +39 -0
  79. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +40 -0
  80. data/sig/generated/lib/solid_objects/component_token.rbs +38 -0
  81. data/sig/generated/lib/solid_objects/component_view.rbs +43 -0
  82. data/sig/generated/lib/solid_objects/configuration.rbs +35 -7
  83. data/sig/generated/lib/solid_objects/database_adapter.rbs +9 -0
  84. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +8 -0
  85. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
  86. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +13 -0
  87. data/sig/generated/lib/solid_objects/errors.rbs +130 -0
  88. data/sig/generated/lib/solid_objects/executor.rbs +12 -0
  89. data/sig/generated/lib/solid_objects/instance_pruner.rbs +36 -0
  90. data/sig/generated/lib/solid_objects/message_pruner.rbs +42 -0
  91. data/sig/generated/lib/solid_objects/message_reference.rbs +3 -0
  92. data/sig/generated/lib/solid_objects/process_pruner.rbs +27 -0
  93. data/sig/generated/lib/solid_objects/reference.rbs +3 -0
  94. data/sig/generated/lib/solid_objects/state_snapshot.rbs +30 -0
  95. data/sig/generated/lib/solid_objects/stream_token.rbs +9 -4
  96. data/sig/generated/lib/solid_objects/sync_deadline.rbs +31 -0
  97. data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +34 -0
  98. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +3 -0
  99. data/sig/generated/lib/solid_objects/test_helper.rbs +25 -0
  100. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +10 -0
  101. data/sig/generated/lib/solid_objects.rbs +12 -0
  102. metadata +34 -1
data/docs/operations.md CHANGED
@@ -34,6 +34,9 @@ Process inspection, cleanup, dead-letter inspection, and retry all require an
34
34
  administration policy that authorizes the CLI context:
35
35
 
36
36
  ```bash
37
+ bundle exec solid_objects prune_messages
38
+ bundle exec solid_objects prune_instances
39
+ bundle exec solid_objects prune_processes
37
40
  bundle exec solid_objects dead_letters
38
41
  bundle exec solid_objects retry_dead_letter 123
39
42
  ```
@@ -56,6 +59,9 @@ Important controls include:
56
59
  - payload, state, and result byte limits
57
60
  - retry attempts and delay
58
61
  - heartbeat interval and alive threshold
62
+ - message retention and per-actor-type overrides
63
+ - opt-in actor-instance retention by actor type
64
+ - stopped-process retention and prune batch size
59
65
 
60
66
  Keep lease duration comfortably above renewal interval and expected database
61
67
  pause time. A handler can exceed the pass-duration budget because Ruby code is
@@ -126,49 +132,81 @@ Alert on:
126
132
  - reconciliation drift;
127
133
  - database lock waits, deadlocks, and SQLite busy errors.
128
134
 
135
+ ## Instrumentation and logging
136
+
137
+ Active Support notifications use the `solid_objects.` prefix. Core events
138
+ include message enqueue/start/completion/failure/rejection, activation
139
+ claim/start/renew/release/deactivation failure, sync timeout/enqueue timeout/
140
+ transaction rejection, commit-action start/completion/failure, effect and
141
+ broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
142
+ retention pruning, process cleanup, and supervisor lifecycle.
143
+
144
+ Payloads contain stable runtime identifiers, actor identity, sequence,
145
+ attempts, ownership generations, and safe exception summaries where relevant.
146
+ Arguments, actor state, results, and outbox payloads are excluded. The bundled
147
+ log subscriber turns the same notifications into structured logger hashes.
148
+
129
149
  ## Retention and backups
130
150
 
131
- The schema has cleanup indexes, but automatic pruning commands are still
132
- roadmap work. Every actor call creates a durable message-history row, including
133
- queries and attribute reads. Choose a retention period from measured call
134
- volume, storage budget, audit needs, and the longest promised synchronous-result
135
- lookup window.
151
+ Every actor call creates a durable message-history row, including queries and
152
+ attribute reads. The default retention policy keeps terminal message history
153
+ for 30 days and stopped process records for 7 days:
136
154
 
137
- An application-owned pruning job can start from this conservative relation:
155
+ `reference.snapshot` is the explicit exception: it performs an authorized
156
+ current-state read without mailbox ordering or a message row.
138
157
 
139
158
  ```ruby
140
- cutoff = 30.days.ago
141
-
142
- prunable_messages = SolidObjects::Message
143
- .where(completed_at: ...cutoff)
144
- .where.not(id: SolidObjects::ReadyMessage.select(:message_id))
145
- .where.not(id: SolidObjects::ClaimedMessage.select(:message_id))
146
- .where.not(id: SolidObjects::DeadLetter.select(:message_id))
147
- .where.not(
148
- id: SolidObjects::DeadLetter
149
- .where.not(retried_message_id: nil)
150
- .select(:retried_message_id)
151
- )
152
- .where.not(
153
- id: SolidObjects::Effect
154
- .where.not(status: "completed")
155
- .select(:message_id)
156
- )
157
- .where.not(
158
- id: SolidObjects::Broadcast
159
- .where.not(status: "delivered")
160
- .select(:message_id)
161
- )
162
-
163
- prunable_messages.in_batches(of: 1_000).delete_all
159
+ SolidObjects.configure do |configuration|
160
+ configuration.message_retention = 30.days
161
+ configuration.message_retention_by_actor_type = {
162
+ "AuditActor" => 365.days,
163
+ "EphemeralCounter" => 1.day
164
+ }
165
+ configuration.instance_retention_by_actor_type = {
166
+ "EphemeralCounter" => 30.days
167
+ }
168
+ configuration.process_retention = 7.days
169
+ configuration.prune_batch_size = 1_000
170
+ end
171
+ ```
172
+
173
+ Both pruning commands are dry-run previews by default:
174
+
175
+ ```bash
176
+ bundle exec solid_objects prune_messages
177
+ bundle exec solid_objects prune_instances
178
+ bundle exec solid_objects prune_processes
179
+ ```
180
+
181
+ After reviewing the counts, execute bounded deletion:
182
+
183
+ ```bash
184
+ bundle exec solid_objects prune_messages --execute
185
+ bundle exec solid_objects prune_instances --execute
186
+ bundle exec solid_objects prune_processes --execute
164
187
  ```
165
188
 
166
- Deleting a message cascades to its completed effects, delivered broadcasts, and
167
- other message-owned records. Test the exact relation against a restored
168
- production snapshot before scheduling it. Keep source and retried messages for
169
- dead letters under investigation, and never prune pending, processing, ready, or
170
- claimed work. Choose a cutoff longer than every `sync` timeout because a caller
171
- whose result row disappears can no longer observe that result.
189
+ Message pruning keeps ready and claimed work, dead letters and their retry
190
+ links, messages with unfinished effects, and messages with undelivered
191
+ broadcasts. Deleting eligible history cascades to completed effects, delivered
192
+ broadcasts, and other message-owned rows. Choose a cutoff longer than every
193
+ `sync` timeout because a caller whose result row disappears can no longer
194
+ observe it.
195
+
196
+ Actor expiration is disabled by default. `prune_instances` considers only
197
+ actor types listed in `instance_retention_by_actor_type`, excludes active or
198
+ paused actors, and preserves ready/claimed mailbox work, scheduled reminders,
199
+ unfinished or dead outboxes, and dead letters. It locks and rechecks every
200
+ candidate before cascading deletion. Preview counts first, then schedule
201
+ `--execute` only after the application has accepted the loss of dormant state
202
+ and completed history.
203
+
204
+ Use authorized `reference.destroy` when deletion is an explicit application
205
+ operation rather than a retention policy.
206
+
207
+ Run stale-process `cleanup` before `prune_processes`. Normal caller processes
208
+ mark their registrations stopped at exit; hard kills remain recoverable through
209
+ heartbeat cleanup.
172
210
 
173
211
  Back up actor tables with the same consistency guarantees as application data.
174
212
  Restoring only instances without their mailboxes/outboxes, or vice versa, can
data/docs/realtime.md CHANGED
@@ -1,13 +1,15 @@
1
1
  # Realtime integration
2
2
 
3
- ## Rendering
3
+ ## Scalars and components
4
4
 
5
5
  `solid_object` performs initial server rendering and emits one
6
6
  `turbo-cable-stream-source` for the actor:
7
7
 
8
8
  ```erb
9
- <%= solid_object ShoppingCartActor.ref(current_user.id) do |cart| %>
9
+ <%= solid_object ShoppingCartActor.ref(current_user.id),
10
+ authorization_context: current_user do |cart| %>
10
11
  Items: <%= cart.items_count %>
12
+ <%= cart.component :summary, observes: %i[items checkout_status] %>
11
13
  <% end %>
12
14
  ```
13
15
 
@@ -15,9 +17,39 @@ Every observable gets a stable opaque DOM ID. Multiple values share the one
15
17
  actor subscription and Action Cable multiplexes actor subscriptions over the
16
18
  browser's physical WebSocket.
17
19
 
18
- `actor.component(:summary)` renders a host partial by convention at
19
- `actors/<actor_class>/_summary`. Initial component rendering is implemented;
20
- durable background component replacement is not yet implemented.
20
+ Scalar observable calls such as `cart.items_count` render stable `<span>`
21
+ targets. Their broadcast remains a direct escaped text replacement.
22
+
23
+ A reactive component declares one or more explicit observable dependencies.
24
+ `actor.component(:summary, observes: ...)` resolves the host partial by
25
+ convention at `actors/<actor_class>/_summary` and wraps it in a stable Turbo
26
+ Frame. Reactive components do not accept `partial:` because a client must
27
+ never influence partial resolution. The older
28
+ `actor.component(:summary, partial: "server/chosen/path")` form remains
29
+ available for initial-only static rendering.
30
+
31
+ The partial receives exactly two component locals:
32
+
33
+ - `actor`, which exposes the declared observables as deeply frozen ordinary
34
+ Ruby values plus `actor_id` and `reference`; and
35
+ - `authorization_context`, the context for this initial render or refresh.
36
+
37
+ `actor.state` is unavailable in reactive components. Reading an observable not
38
+ listed in `observes:` raises `UnknownComponentDependency`. This keeps
39
+ invalidation correct and prevents a partial from silently depending on state
40
+ that cannot wake it.
41
+
42
+ ```erb
43
+ <ul>
44
+ <% actor.recent_messages.each do |message| %>
45
+ <li><%= message.fetch("body") %></li>
46
+ <% end %>
47
+ </ul>
48
+ ```
49
+
50
+ Arrays, hashes, loops, conditionals, nested markup, and host helper output are
51
+ normal ERB. Escaping remains Action View's responsibility; Solid Objects never
52
+ marks actor strings as HTML safe.
21
53
 
22
54
  ## Authorization
23
55
 
@@ -26,18 +58,69 @@ it does not grant access. `ActorChannel` verifies the token, resolves the actor
26
58
  through the registry, calls `authorize_subscription`, and streams only after
27
59
  approval.
28
60
 
29
- Initial observable method reads separately call `authorize_query`. Never
30
- authorize solely from actor ID, token possession, stream name, or DOM ID.
61
+ Initial scalar and component reads call `authorize_query` with the context
62
+ passed to `solid_object`. The refresh controller resolves a new request context
63
+ through `component_authorization_context`, then calls `authorize_query` again
64
+ for the component name and every declared dependency. The default resolver
65
+ supplies the engine controller; applications commonly resolve it to
66
+ `Current.user`:
67
+
68
+ ```ruby
69
+ configuration.component_authorization_context = ->(controller:) { Current.user }
70
+ ```
71
+
72
+ The three contexts are intentionally different:
73
+
74
+ | Boundary | Authorization context |
75
+ | --- | --- |
76
+ | Initial Action View render | Explicit `authorization_context:` passed to `solid_object` |
77
+ | Action Cable subscription | The authenticated Cable connection |
78
+ | Component refresh | Value returned by `component_authorization_context` for the engine controller request |
79
+
80
+ Do not substitute a signed token for any of them. Never authorize solely from
81
+ actor ID, token possession, stream name, component name, or DOM ID.
31
82
 
32
83
  ## Broadcast durability
33
84
 
34
85
  The actor's fenced commit compares observables before and after the turn and
35
- inserts one broadcast row per changed value. A broadcast process later sends a
36
- Turbo replacement and records delivery. No direct broadcast occurs inside the
37
- actor transaction.
86
+ inserts one broadcast row per changed value. The actor state, monotonic
87
+ `state_revision`, message completion, and broadcast rows commit atomically. A
88
+ rolled-back or fenced-out turn therefore cannot invalidate a component.
89
+
90
+ A broadcast process later sends small invalidation metadata and records
91
+ delivery. Its scalar Turbo replacement is transmitted only when that observable
92
+ target was rendered and signed into the scope's stream token. Component-only
93
+ dependencies therefore do not expose their serialized values over Cable. The
94
+ runtime never stores or broadcasts personalized component HTML. Each
95
+ authorized browser requests affected components through the engine endpoint
96
+ with its normal cookies. Responses are `private, no-store`.
97
+
98
+ Several changed dependencies from one message sequence produce one logical
99
+ refresh for a component. An unrelated observable does not refresh it. If a
100
+ newer invalidation arrives while a Turbo Frame request is in flight, the new
101
+ frame replaces the old frame element; the detached older response has no
102
+ current target.
38
103
 
39
104
  If Cable delivery is lost, reconnecting `ActorChannel` transmits replacements
40
- from current actor state. The durable state row remains source of truth.
105
+ from current actor state. It compares the signed component revision with the
106
+ latest `(instance_id, state_revision)` pair and refreshes stale components.
107
+ The incarnation ID handles destroy-and-recreate; the state revision handles
108
+ ordered commits within one incarnation. Out-of-order invalidations at or below
109
+ the last transmitted pair are ignored. The durable state row remains source of
110
+ truth.
111
+
112
+ The component endpoint rejects a requested revision newer than the committed
113
+ snapshot. This is a final server-side guard; browser safety primarily comes
114
+ from monotonic channel filtering and replacing the entire Turbo Frame
115
+ generation.
116
+
117
+ ## Cost model
118
+
119
+ The durable row cost is unchanged: one broadcast row per changed observable,
120
+ containing its JSON value and the message/instance references needed to derive
121
+ invalidation metadata. No rendered document is stored. Each affected component
122
+ adds one authorized GET and one partial render per non-coalesced state
123
+ revision. Scalar observables remain the cheaper path for one text value.
41
124
 
42
125
  ## Deployment
43
126
 
@@ -48,4 +131,15 @@ require Redis.
48
131
 
49
132
  Changing or removing observable names during a rolling deploy can strand old
50
133
  broadcast rows or old DOM targets. Keep old names compatible until the outbox
51
- and old pages have drained.
134
+ and old pages have drained. Keep component partial names and dependency
135
+ observables compatible across a rolling deploy for the same reason.
136
+
137
+ Reactive components require the engine mount because their signed refresh path
138
+ is generated from that mount. Applications with more than one engine mount can
139
+ set `component_path_resolver` to return the intended same-origin
140
+ `components_path`.
141
+
142
+ ```ruby
143
+ # config/routes.rb
144
+ mount SolidObjects::Engine => "/solid_objects"
145
+ ```
data/docs/roadmap.md CHANGED
@@ -15,10 +15,17 @@
15
15
  - Transactional effects with success/failure actor messages
16
16
  - Actor-to-actor asynchronous outbox delivery
17
17
  - One-shot and recurring reminders with `:latest` or `:all` catch-up
18
- - Durable observable broadcast outbox and authorized Action Cable refresh
18
+ - Durable observable invalidations, scalar Turbo replacement, and authorized
19
+ request-time ERB component refresh
19
20
  - Reconciliation read APIs
20
21
  - Installation doctor, authorization reference, fit guide, and legacy-state
21
22
  migration cookbook
23
+ - Handler Active Record write isolation, same-database commit actions, ambient
24
+ transaction rejection, adapter lock/query deadlines, structured sync timeout
25
+ diagnostics, and result recovery
26
+ - Bounded message/process pruning, actor-type opt-in instance expiration,
27
+ graceful caller shutdown, committed state snapshots, and an opt-in Minitest
28
+ helper
22
29
  - SQLite, PostgreSQL, and MySQL integration suites
23
30
  - Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
24
31
  RuboCop policy, and a warning-free Brakeman scan
@@ -29,8 +36,9 @@
29
36
  role or run periodic maintenance automatically.
30
37
  - Wake-up strategy: in-process signaling plus durable polling and injection are
31
38
  implemented; PostgreSQL `LISTEN/NOTIFY` and optional Redis adapters are not.
32
- - Realtime: observable replacement and reconnect refresh are implemented;
33
- durable component replacement and Turbo append actions are not.
39
+ - Realtime: scalar and dependency-driven ERB component replacement,
40
+ personalized refresh authorization, revision fencing, coalescing, and
41
+ reconnect convergence are implemented; Turbo append actions are not.
34
42
  - Backpressure: mailbox/payload/state/result caps and fair yields exist;
35
43
  distributed per-actor rate limits and global admission control do not.
36
44
  - Administration: actor and dead-letter views plus policy hooks exist; richer
@@ -43,11 +51,10 @@
43
51
  1. Add automatic supervisor role replacement and periodic dead-process cleanup.
44
52
  2. Add PostgreSQL notification and optional Redis wake-up adapters with latency
45
53
  benchmarks and polling-race tests.
46
- 3. Add bounded retention/pruning commands and result lookup by request ID.
47
- 4. Add deadlock, lock-timeout, and SQLite-busy retry classification.
54
+ 3. Add result lookup by request ID and broader deadlock retry classification.
55
+ 4. Add scheduled retention and stale-process maintenance.
48
56
  5. Add database/server-version checks and MySQL InnoDB verification at boot.
49
- 6. Add component broadcast rendering, Turbo append intents, and reconnect tests
50
- in a full browser.
57
+ 6. Add Turbo append intents and expand reconnect coverage in a full browser.
51
58
  7. Add distributed rate limits, global admission hooks, and cache-capacity
52
59
  eviction.
53
60
  8. Expand security scanning and run compatibility CI across supported Rails and
data/docs/security.md CHANGED
@@ -13,9 +13,11 @@ risk for every hook and includes a tenant-aware policy example.
13
13
  Method-style reference calls do not bypass these hooks. Public instance methods
14
14
  declared on an actor are part of its remotely addressable message surface and
15
15
  delegate to the authorized synchronous invocation path. Keep implementation
16
- helpers private or protected. Query and attribute methods use the separate
17
- query authorization policy. Explicit `async` message delivery uses the same
18
- message authorization policy as direct calls.
16
+ helpers private or protected. Query, attribute, observable, and committed
17
+ `snapshot` reads use the separate query authorization policy. Explicit `async`
18
+ message delivery uses the same message authorization policy as direct calls.
19
+ Recovering a timed-out result through `MessageReference#wait` reauthorizes the
20
+ stored operation.
19
21
  `reference.destroy` delegates to `authorize_destroy` before checking whether
20
22
  the actor exists, so denial does not reveal actor existence.
21
23
 
@@ -54,10 +56,30 @@ host authentication and audit their use.
54
56
  Instrumentation excludes arguments, state, results, and effect payloads by
55
57
  default. Review custom logging and effect handlers for accidental disclosure.
56
58
 
59
+ ## Handler database access
60
+
61
+ Handlers, observables, lifecycle hooks, and state migrations run with Active
62
+ Record writes prevented. They may query application records, but a direct
63
+ write becomes
64
+ `SolidObjects::ApplicationWriteForbidden` and dead-letters without retry.
65
+ This prevents application data from escaping a later actor failure or stale
66
+ fence.
67
+
68
+ Registered commit actions are privileged application code. They execute inside
69
+ the fenced actor transaction and receive stored JSON arguments, so register
70
+ only fixed names, validate record ownership again, and keep the block to
71
+ bounded database work. Never perform network I/O or authorize solely from a
72
+ record ID in commit-action arguments.
73
+
57
74
  Actor destruction is not an administrative shortcut. Authorize tenancy and
58
75
  ownership explicitly in `authorize_destroy`; knowledge of an actor ID is never
59
76
  permission to delete its state or queued work.
60
77
 
78
+ Instance pruning is likewise destructive and requires administration
79
+ authorization. Only opt-in actor types are eligible, and live work is
80
+ preserved, but the host application must decide whether dormant state and
81
+ completed history may expire.
82
+
61
83
  ## Denial of service
62
84
 
63
85
  Configure mailbox and byte limits. Add host rate limiting before public actor
@@ -15,6 +15,8 @@ end
15
15
 
16
16
  Migration runs in memory during activation. The new version is persisted only
17
17
  with the next successful fenced message commit.
18
+ Migration blocks may read application records but cannot write them directly;
19
+ the same Active Record write guard used for handlers applies before activation.
18
20
 
19
21
  ## Runtime rules
20
22
 
@@ -10,7 +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 initial `solid_object` rendering and live observable
14
- replacement. Live cart-summary replacement and chat-message append are
15
- deliberately not shown as working behavior because component broadcasts and
16
- Turbo append intents remain roadmap items.
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.
@@ -1,5 +1,5 @@
1
1
  <ol>
2
- <% actor.state.recent_messages.each do |message| %>
2
+ <% actor.recent_messages.each do |message| %>
3
3
  <li id="message_<%= message.fetch("id") %>">
4
4
  <strong><%= message.fetch("user_id") %></strong>
5
5
  <%= message.fetch("body") %>
@@ -1,8 +1,8 @@
1
- <%= solid_object @room do |room| %>
1
+ <%= solid_object @room, authorization_context: current_user do |room| %>
2
2
  <p>
3
3
  Present:
4
4
  <%= room.presence %>
5
5
  </p>
6
6
 
7
- <%= room.component :messages %>
7
+ <%= room.component :messages, observes: :recent_messages %>
8
8
  <% end %>
@@ -2,16 +2,22 @@
2
2
 
3
3
  SolidObjects.configure do |configuration|
4
4
  configuration.authorize_message = lambda do |actor_type:, actor_id:, authorization_context:, **|
5
- next false unless authorization_context.respond_to?(:current_user)
5
+ user = if authorization_context.respond_to?(:current_user)
6
+ authorization_context.current_user
7
+ else
8
+ authorization_context
9
+ end
10
+ next false unless user
6
11
 
7
12
  if actor_type == ShoppingCartActor.actor_type
8
- authorization_context.current_user.id.to_s == actor_id
13
+ user.id.to_s == actor_id
9
14
  else
10
- ChatRoomPolicy.new(authorization_context.current_user).access?(actor_id)
15
+ ChatRoomPolicy.new(user).access?(actor_id)
11
16
  end
12
17
  end
13
18
  configuration.authorize_query = configuration.authorize_message
14
19
  configuration.authorize_subscription = configuration.authorize_message
20
+ configuration.component_authorization_context = ->(controller:) { Current.user }
15
21
  end
16
22
 
17
23
  SolidObjects.register_effect(:charge_payment) do |arguments, context|
@@ -5,14 +5,35 @@ SolidObjects.configure do |configuration|
5
5
  configuration.effect_worker_count = 1
6
6
  configuration.broadcast_worker_count = 1
7
7
  configuration.reminder_scheduler_count = 1
8
+ configuration.message_retention = 30.days
9
+ configuration.process_retention = 7.days
10
+ configuration.prune_batch_size = 1_000
11
+
12
+ # Override message retention only for actor types with different audit or
13
+ # privacy requirements:
14
+ #
15
+ # configuration.message_retention_by_actor_type = {
16
+ # "AuditActor" => 365.days,
17
+ # "EphemeralCounter" => 1.day
18
+ # }
19
+ #
20
+ # Actor instances never expire unless their type is listed here. Expiration
21
+ # removes idle state and completed history, so start with the preview command:
22
+ #
23
+ # configuration.instance_retention_by_actor_type = {
24
+ # "EphemeralCounter" => 30.days
25
+ # }
26
+ #
27
+ # bundle exec solid_objects prune_instances
8
28
 
9
29
  # Every policy denies by default, so a fresh installation is intentionally
10
30
  # inert. Replace these policies before invoking actors.
11
31
  #
12
32
  # Message and query policies gate direct calls, sync, async, and state reads.
13
33
  # Destroy removes an actor and all of its durable work. Subscription gates
14
- # Action Cable streams. Administration gates engine pages and operational
15
- # commands. Keep the last three denied until their callers are authenticated.
34
+ # Action Cable streams. Administration gates engine pages, pruning, and
35
+ # operational commands. Keep the last three denied until their callers are
36
+ # authenticated.
16
37
  #
17
38
  # Prefer policies that bind actor_type and actor_id to a trusted
18
39
  # authorization_context. See:
@@ -27,4 +48,14 @@ SolidObjects.configure do |configuration|
27
48
  configuration.authorize_destroy = ->(**) { false }
28
49
  configuration.authorize_subscription = ->(**) { false }
29
50
  configuration.authorize_administration = ->(**) { false }
51
+
52
+ # Configure component_authorization_context to return the authenticated
53
+ # principal used for reactive component refreshes.
54
+
55
+ # On hosts where shell access is already an authenticated administrative
56
+ # boundary, this enables only gem commands that pass the CLI context:
57
+ #
58
+ # configuration.authorize_administration = lambda do |authorization_context:, **|
59
+ # authorization_context.is_a?(Hash) && authorization_context[:source] == "cli"
60
+ # end
30
61
  end
@@ -91,9 +91,25 @@ module SolidObjects
91
91
  # @rbs () -> void
92
92
  def deactivate
93
93
  actor.deactivate
94
- lease.release
95
- rescue LostActivation
96
- nil
94
+ rescue => error
95
+ SolidObjects.instrument(
96
+ :"activation.deactivation_failed",
97
+ instance_id: lease.instance_id,
98
+ actor_type: actor.class.actor_type,
99
+ actor_id: actor.actor_id,
100
+ owner_id: lease.owner_id,
101
+ generation: lease.generation,
102
+ error_class: error.class.name,
103
+ error_message: error.message
104
+ )
105
+ SolidObjects.configuration.logger.error(
106
+ "SolidObjects activation deactivation failed " \
107
+ "actor_type=#{actor.class.actor_type.inspect} " \
108
+ "actor_id=#{actor.actor_id.inspect} " \
109
+ "error_class=#{error.class.name}"
110
+ )
111
+ ensure
112
+ release_lease
97
113
  end
98
114
 
99
115
  private
@@ -129,13 +145,26 @@ module SolidObjects
129
145
 
130
146
  # @rbs (Instance) -> Actor
131
147
  def build_actor(instance)
132
- state_data = actor_class.definition.migrate_state(instance.state_version, instance.state)
148
+ state_data = ApplicationWriteGuard.call(
149
+ actor_type: instance.actor_type,
150
+ actor_id: instance.actor_id,
151
+ operation: "state_migration"
152
+ ) do
153
+ actor_class.definition.migrate_state(instance.state_version, instance.state)
154
+ end
133
155
  actor_class.new(
134
156
  actor_id: instance.actor_id,
135
157
  state: State.new(actor_class.definition.state_definition, state_data)
136
158
  )
137
159
  end
138
160
 
161
+ # @rbs () -> void
162
+ def release_lease
163
+ lease.release
164
+ rescue LostActivation
165
+ nil
166
+ end
167
+
139
168
  # @rbs () -> Message?
140
169
  def claim_next_message
141
170
  lease.fenced_transaction do |instance|