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/architecture.md CHANGED
@@ -120,7 +120,11 @@ instance first prevents a claimed reminder from recreating a destroyed actor.
120
120
 
121
121
  ### Broadcast worker
122
122
 
123
- The broadcast worker claims committed observable-change rows, renders idempotent Turbo replacements, broadcasts to a signed actor stream, and records delivery. Current actor state remains the reconnect source of truth.
123
+ The broadcast worker claims committed observable-change rows, renders
124
+ idempotent scalar Turbo replacements with component invalidation metadata,
125
+ broadcasts to a signed actor stream, and records delivery. It never renders
126
+ personalized component HTML. Current actor state remains the reconnect and
127
+ request-time component source of truth.
124
128
 
125
129
  ### Process registry
126
130
 
@@ -150,6 +154,12 @@ snapshots are deeply frozen. Use `async` for durable fire-and-forget delivery
150
154
  and `sync` for dynamic operation names. The explicit `message` DSL remains
151
155
  available for dynamic definitions.
152
156
 
157
+ `reference.snapshot` is the explicit unordered read path. It invokes query
158
+ authorization, reads the most recently committed instance state without
159
+ creating a message or activation, applies required state migrations in memory,
160
+ and returns deeply frozen declared attributes. It can race with an in-flight
161
+ turn. `SolidObjects.mutable_copy` creates an independent mutable JSON value.
162
+
153
163
  `message` and `query` both execute as durable mailbox turns. A query may not
154
164
  mutate state. The executor detects query mutation and fails the message. An
155
165
  observable is a named projection of state used by server rendering and realtime
@@ -161,6 +171,8 @@ Lifecycle hooks are deterministic local hooks:
161
171
  - `on_deactivate` runs only on graceful local deactivation. Its state changes are not persisted and it must not be used for durable work. Explicit destruction does not run lifecycle hooks.
162
172
 
163
173
  Durable application cleanup belongs in messages, reminders, or effects.
174
+ Handlers, observable blocks, lifecycle hooks, and state migrations all execute
175
+ with application Active Record writes prevented.
164
176
 
165
177
  ## Enqueue and sequence allocation
166
178
 
@@ -248,6 +260,8 @@ Actor code then executes with no open database transaction and no pinned connect
248
260
 
249
261
  - Read and mutate its in-memory state for a message
250
262
  - Read state for a query
263
+ - Read application records
264
+ - Stage same-database commit actions
251
265
  - Stage effects
252
266
  - Stage reminders
253
267
  - Stage asynchronous actor messages
@@ -258,8 +272,16 @@ It cannot:
258
272
  - Perform a synchronous actor-to-actor wait
259
273
  - Assume execution happens once
260
274
  - Commit actor state directly
275
+ - Write application records directly
276
+ - Perform external I/O that must be atomic with actor state
261
277
 
262
- After actor code, the executor validates state and staged data as JSON and computes changed observables.
278
+ Rails write prevention turns a direct Active Record write into
279
+ `ApplicationWriteForbidden` before it reaches the database. Handler and
280
+ observable failures become nonretryable message failures. Activation and
281
+ migration failures happen before the message is claimed. A deactivation-hook
282
+ failure is instrumented and logged, but cannot replace an already committed
283
+ result or prevent best-effort lease release. After actor code, the executor
284
+ validates state and staged data as JSON and computes changed observables.
263
285
 
264
286
  ## Fenced commit
265
287
 
@@ -268,14 +290,16 @@ Successful completion uses one database transaction:
268
290
  1. Lock the instance row.
269
291
  2. Verify owner, activation token, generation, and an unexpired lease using database time.
270
292
  3. Lock the durable message and verify its claimed membership belongs to that owner, activation token, and generation.
271
- 4. Update native JSON state and state version.
272
- 5. Store the completion timestamp and result on the durable message and delete claimed membership.
273
- 6. Insert staged effects.
274
- 7. Insert or update staged reminders.
275
- 8. Insert staged actor-message outbox rows.
276
- 9. Insert changed-observable broadcast rows.
277
- 10. Update actor last-used time.
278
- 11. Commit.
293
+ 4. Execute registered same-database commit actions.
294
+ 5. Update native JSON state and state version.
295
+ 6. Advance the actor state revision to the completed message sequence.
296
+ 7. Store the completion timestamp and result on the durable message and delete claimed membership.
297
+ 8. Insert staged effects.
298
+ 9. Insert or update staged reminders.
299
+ 10. Insert staged actor-message outbox rows.
300
+ 11. Insert changed-observable broadcast rows.
301
+ 12. Update actor last-used time.
302
+ 13. Commit.
279
303
 
280
304
  Any lease or message predicate failure raises `LostActivation` and rolls back every item. The stale worker discards its in-memory activation.
281
305
 
@@ -346,11 +370,25 @@ overhead at or below 100 milliseconds; polling fallback can pay up to
346
370
  Caller timeout:
347
371
 
348
372
  - Raises `SolidObjects::SyncTimeout`.
373
+ - Reports durable message, blocker, and activation-owner diagnostics.
374
+ - Exposes a `message_reference` that can reauthorize and wait again.
349
375
  - Does not cancel or delete the message.
350
376
  - Does not prevent later execution.
351
377
  - Leaves the result available until retention cleanup.
352
378
 
353
- The durable row remains after timeout and can be inspected directly by message ID. A public request-ID lookup and bounded retention commands are not yet implemented.
379
+ The configured timeout begins before durable enqueue. PostgreSQL transaction
380
+ lock and statement timeouts, MySQL execution and InnoDB lock-wait timeouts, and
381
+ SQLite busy timeout bound database contention. MySQL rounds lock waits up to
382
+ its one-second minimum. If enqueue cannot commit, `SyncEnqueueTimeout` is
383
+ raised and no durable result exists. Once Ruby handler code starts, it is not
384
+ safely preempted and may outlive the caller budget.
385
+
386
+ The durable row remains after a normal wait timeout and can be recovered with
387
+ `error.message_reference.wait`. Bounded retention commands are implemented;
388
+ public request-ID lookup is not. A sync call made with an already-open
389
+ transaction on the Solid Objects connection raises `SyncInsideTransaction`
390
+ before enqueue. This avoids savepoint enlistment, lock retention until the
391
+ outer commit, and callers timing out on work they indirectly block.
354
392
 
355
393
  `async` performs the same durable enqueue without caller assistance or result
356
394
  waiting and immediately returns a `MessageReference`. Runtime workers process
@@ -427,25 +465,61 @@ Large repairs use `async(..., available_at:)` to spread work over an application
427
465
 
428
466
  ## Realtime integration
429
467
 
430
- `solid_object` performs an authorized state read for initial rendering and emits:
468
+ `solid_object` performs an authorized state read for initial rendering and
469
+ emits:
431
470
 
432
471
  - A stable scope DOM ID derived from actor type and a SHA-256 digest of actor ID
433
472
  - One Turbo Cable subscription element for the actor
434
473
  - Stable child target IDs for values and components
435
474
  - A signed actor token used by the channel subscription
475
+ - Signed component registrations containing a conventional component name,
476
+ explicit observable dependencies, the initial actor incarnation/revision,
477
+ and a same-origin engine refresh path
436
478
 
437
479
  ```erb
438
480
  <%= solid_object current_cart do |cart| %>
439
481
  Cart items: <%= cart.items_count %>
440
- <%= cart.component :summary %>
482
+ <%= cart.component :summary, observes: %i[items checkout_status] %>
441
483
  <% end %>
442
484
  ```
443
485
 
444
486
  The signed token proves integrity, not authorization. `ActorChannel#subscribed` verifies the token, resolves the registered actor type, invokes `authorize_subscription`, and only then streams.
445
487
 
446
- Broadcast replacements happen after the actor transaction commits because only a committed broadcast outbox row can be delivered. Multiple values share the same Action Cable connection and one actor subscription.
447
-
448
- Each channel subscription transmits current observable replacements before streaming future broadcasts, including after reconnect. Missing a broadcast therefore creates temporary staleness, not permanent divergence.
488
+ Scalar observable calls remain direct escaped Turbo replacements. A reactive
489
+ component resolves only `actors/<actor_class>/_<component>`, receives its
490
+ declared observables as frozen Ruby values, and cannot read raw state or a
491
+ dependency it did not declare. A static initial-only component can still use a
492
+ server-selected explicit partial; a reactive component cannot.
493
+
494
+ Broadcast replacements happen after the actor transaction commits because only
495
+ a committed broadcast outbox row can be delivered. Multiple scalar values and
496
+ components share the same Action Cable connection and one actor subscription.
497
+ One outbox row still exists per changed observable.
498
+
499
+ The shared stream contains invalidation metadata and scalar HTML only for the
500
+ scalar targets signed into that scope's stream token. Component-only
501
+ dependencies do not send their values to the browser, and the stream never
502
+ contains personalized component HTML. For each subscription, `ActorChannel`
503
+ matches the changed observable to registered component dependencies. It
504
+ coalesces multiple dependencies at the same message sequence and drops older
505
+ revision pairs. A component invalidation replaces its stable target with a
506
+ Turbo Frame whose source is the signed engine endpoint.
507
+
508
+ The browser then makes an ordinary cookie-bearing HTTP request. The engine
509
+ controller derives a request-specific context through
510
+ `component_authorization_context`, calls `authorize_query` for the component
511
+ name and every declared dependency, renders the host partial from a new
512
+ committed snapshot, and returns `private, no-store` HTML. Subscribers to the
513
+ same actor can therefore receive different HTML without sharing it through
514
+ Cable or the database.
515
+
516
+ Each channel subscription transmits current scalar replacements and compares
517
+ each component's signed initial revision against the latest committed
518
+ `(instance_id, state_revision)` pair, including after reconnect. Missing a
519
+ broadcast therefore creates temporary staleness, not permanent divergence.
520
+ The instance primary key distinguishes destroy-and-recreate incarnations.
521
+ Replacing the full frame on each newer invalidation detaches an older in-flight
522
+ frame, preventing its slower response from replacing the current generation.
449
523
 
450
524
  ## Authorization
451
525
 
@@ -464,6 +538,11 @@ recognizable.
464
538
 
465
539
  No controller, channel, or administrative command treats an actor ID, message ID, request ID, or signed stream name as authorization.
466
540
 
541
+ Initial component rendering, Cable subscription, and request-time component
542
+ refresh deliberately use different authorization contexts. Signed component
543
+ tokens constrain actor identity, component convention, dependencies, revision,
544
+ and same-origin refresh path but never grant access.
545
+
467
546
  Actor IDs are bounded UTF-8 strings and never become constant names, SQL identifiers, file paths, or raw stream names.
468
547
 
469
548
  ## Serialization
@@ -505,6 +584,7 @@ end
505
584
  ```
506
585
 
507
586
  Activation applies each step in order. Missing steps, cycles, non-JSON output, or stored versions newer than code fail activation.
587
+ Migration code is subject to the same application-write guard as handlers.
508
588
 
509
589
  Refusing newer stored state is a runtime invariant, not an operator recommendation. The worker does not invoke lifecycle hooks or message code when `stored_state_version > actor_class.state_version`.
510
590
 
@@ -573,6 +653,19 @@ An activation becomes idle when it has no due earliest message and no turn in fl
573
653
  Graceful release conditionally clears owner and expiration only if the generation still matches. Expired leases need no explicit cleanup before another worker claims them.
574
654
 
575
655
  `on_deactivate` is best effort and nondurable. It may not run on crash and cannot be the source of a correctness requirement.
656
+ A failing hook is logged and instrumented as
657
+ `solid_objects.activation.deactivation_failed`; lease release is still
658
+ attempted and a committed synchronous result is preserved.
659
+
660
+ ## Retention
661
+
662
+ Terminal messages and stopped process rows have configurable bounded pruning.
663
+ Actor instances never expire by default. Types listed in
664
+ `instance_retention_by_actor_type` become eligible after their idle cutoff only
665
+ when they are unowned, unpaused, and have no ready/claimed work, scheduled
666
+ reminder, unfinished or dead outbox, or dead letter. The pruner locks and
667
+ rechecks each candidate before cascading deletion. All pruning commands
668
+ preview by default and require administration authorization.
576
669
 
577
670
  ## Advisory locks
578
671
 
@@ -592,7 +685,7 @@ PostgreSQL transaction-level advisory locks may be used for optional singleton m
592
685
  | Create actor and allocate message sequence | Instance insert/lock, sequence increment, durable message and ready-membership inserts |
593
686
  | Claim activation | Backend claim transaction, generation increment, owner and expiry |
594
687
  | Claim next message | Move ready membership to claimed membership conditioned on lease |
595
- | Successful message commit | Fenced state, durable message result, claimed-membership deletion, effects, reminders, actor outbox, broadcasts |
688
+ | Successful message commit | Fenced state and monotonic revision, durable message result, claimed-membership deletion, effects, reminders, actor outbox, broadcasts |
596
689
  | Failed message attempt | Conditional error, claimed deletion, ready reinsertion or dead letter |
597
690
  | Renew or release lease | Conditional instance update |
598
691
  | Destroy actor | Instance identity lock and cascading delete of state, mailbox, reminders, and outboxes |
@@ -612,7 +705,7 @@ The semantic guarantees are common, but their coordination implementations diffe
612
705
  | JSON state | JSONB | JSON | Rails JSON type |
613
706
  | Executable-work indexes | Ready/claimed membership tables | Ready/claimed membership tables | Ready/claimed membership tables |
614
707
  | Write isolation | Row locks and MVCC | InnoDB row/next-key locks and MVCC | One writer, serializable writes |
615
- | Contention retry | Database/Active Record behavior; explicit classification is roadmap | Database/Active Record behavior; explicit classification is roadmap | Busy timeout; explicit busy classification is roadmap |
708
+ | Sync contention deadline | Transaction lock and statement timeouts | Execution timeout and one-second-granularity InnoDB lock timeout | Busy timeout |
616
709
  | Lease clock | Database current time | Database current time | Database current time |
617
710
 
618
711
  All backends use unique identity and sequence constraints, short transactions, and conditional owner/generation/expiry fencing. Passing one backend's suite is not evidence for another.
@@ -633,14 +726,14 @@ All backends use unique identity and sequence constraints, short transactions, a
633
726
  12. **How are leases renewed?** Conditional database update by instance, owner, generation, and unexpired lease.
634
727
  13. **How does graceful shutdown work?** Stop claims, finish current turn within timeout, release cached leases, stop heartbeat, mark process stopped.
635
728
  14. **How does synchronous invocation work across processes?** The caller first tries to claim and execute the actor locally. If another process owns it, a wake-up adapter prompts a durable result query and bounded polling remains the fallback.
636
- 15. **What happens after caller timeout?** The durable message continues and its eventual result remains on the message row.
637
- 16. **How are results cleaned up?** The schema has cleanup indexes, but bounded retention tooling is not implemented yet.
729
+ 15. **What happens after caller timeout?** A committed message continues and its eventual result can be recovered with the timeout's authorized message reference. An enqueue timeout leaves no message. Running Ruby code is not preempted.
730
+ 16. **How are results cleaned up?** `prune_messages` deletes eligible terminal history in bounded batches after global or per-actor retention. It previews by default and preserves live work, dead letters, retry links, and unfinished outboxes.
638
731
  17. **How are large mailboxes managed?** The implemented controls are the per-actor mailbox cap, payload caps, and fair activation yields; rate and global admission controls remain roadmap work.
639
- 18. **How are completed messages pruned?** Cleanup indexes support future bounded pruning; the initial release does not automatically prune them.
732
+ 18. **How are completed messages pruned?** Operators schedule the dry-run-reviewed `prune_messages --execute` command. Solid Objects does not run deletion automatically.
640
733
  19. **How are state migrations performed?** Explicit one-step actor migrations on activation, persisted only with a successful fenced commit.
641
734
  20. **What happens during rolling deploys?** Newer state can make old workers incompatible; deploys must preserve backward readability or drain old workers.
642
735
  21. **How are subscriptions authorized?** Verify signed identity, resolve registered type, invoke host authorization, then stream.
643
- 22. **How are lost broadcasts recovered?** Current-state refresh after reconnect; durable outbox retries server delivery.
736
+ 22. **How are lost broadcasts recovered?** Current-state scalar replacement and authorized component refresh after reconnect; durable outbox retries server delivery.
644
737
  23. **How are actor-to-actor cycles handled?** Synchronous actor waits are rejected; asynchronous request/result messages avoid call-stack cycles.
645
- 24. **Which operations are transactional?** The transaction map above lists every atomic boundary; actor code and I/O are outside.
738
+ 24. **Which operations are transactional?** The transaction map above lists every atomic boundary. Actor code and external I/O are outside; registered same-pool commit actions execute inside the fenced state/message transaction.
646
739
  25. **Which guarantees depend on PostgreSQL?** None of the public semantics are PostgreSQL-only. PostgreSQL and MySQL depend on row-lock claiming; SQLite depends on serialized write transactions. Each backend's guarantee depends on its adapter-specific integration tests.
@@ -10,14 +10,53 @@ intentionally inert until the host application defines its trust boundary.
10
10
  | Policy | Gates | Caller context | Risk if opened globally |
11
11
  | --- | --- | --- | --- |
12
12
  | `authorize_message` | Direct actor methods, explicit `sync` messages, and public `async` enqueue | Value passed as `authorization_context:`; often a user, service principal, or trusted internal marker | Anyone reaching the call site can mutate any known actor identity |
13
- | `authorize_query` | Attribute reads, declared queries, observable reads, and component reads | Explicit call context or the Rails view context supplied by `solid_object` | Actor state can leak across users or tenants |
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, and dead-letter inspection/retry | Rails controller or `{ source: "cli" }` | Operational metadata, arguments, errors, and retries become exposed or mutable |
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 |
17
17
 
18
- Internal reminder, effect-callback, and actor-to-actor deliveries come from
18
+ Waiting again through `MessageReference#wait` reauthorizes the stored
19
+ invocation as a message or query. Internal reminder, effect-callback, and
20
+ actor-to-actor deliveries come from
19
21
  already committed runtime rows and do not re-enter the public client policy.
20
22
 
23
+ ## Realtime authorization contexts
24
+
25
+ Reactive components cross three Rails execution contexts and authorize at all
26
+ three boundaries:
27
+
28
+ 1. `solid_object(..., authorization_context:)` uses the explicit Action View
29
+ render context for initial scalar and component reads.
30
+ 2. `ActorChannel` passes its authenticated `ActionCable::Connection` to
31
+ `authorize_subscription`.
32
+ 3. `ComponentsController` resolves a fresh context for the cookie-bearing HTTP
33
+ request and calls `authorize_query` for the component name and every
34
+ dependency.
35
+
36
+ Configure the refresh resolver when the query policy expects a user or service
37
+ principal rather than the engine controller:
38
+
39
+ ```ruby
40
+ SolidObjects.configure do |configuration|
41
+ configuration.component_authorization_context = lambda do |controller:|
42
+ Current.user
43
+ end
44
+ end
45
+ ```
46
+
47
+ Authentication middleware must populate `Current.user` for the refresh
48
+ request. Do not copy an Action View object or Cable connection into the signed
49
+ token. Those objects are request-specific and the token provides integrity,
50
+ not authorization.
51
+
52
+ Component partials receive the resolved value as the
53
+ `authorization_context` local, allowing two authorized viewers to render
54
+ different projections. Responses use `Cache-Control: private, no-store`.
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.
59
+
21
60
  ## A tenant-aware policy
22
61
 
23
62
  Pass the authenticated user as the call context:
@@ -80,6 +119,17 @@ Keep `authorize_destroy`, `authorize_subscription`, and
80
119
  Replace unconditional policies before exposing actor IDs to controllers, API
81
120
  clients, MCP tools, jobs carrying user input, or browser subscriptions.
82
121
 
122
+ For commands executed only on hosts where shell access is already the
123
+ authenticated administration boundary, the generated initializer shows an
124
+ optional CLI-scoped policy:
125
+
126
+ ```ruby
127
+ configuration.authorize_administration = lambda do |authorization_context:, **|
128
+ authorization_context.is_a?(Hash) &&
129
+ authorization_context[:source] == "cli"
130
+ end
131
+ ```
132
+
83
133
  Run `bin/rails solid_objects:doctor` after configuration. Its neutral policy
84
134
  probe is deliberately conservative: a context-aware policy may correctly warn
85
135
  because it denies a `nil` context.
data/docs/benchmarks.md CHANGED
@@ -66,8 +66,8 @@ Attribute reads are therefore not free snapshots from the instance row. They
66
66
  are ordered durable query messages and grow message history exactly like
67
67
  writes.
68
68
 
69
- Version 0.2 has cleanup indexes but no built-in pruning command. Budget message
70
- growth as:
69
+ Built-in pruning previews by default and must be scheduled explicitly. Budget
70
+ message growth before retention as:
71
71
 
72
72
  ```text
73
73
  daily durable messages = daily actor writes + daily actor reads + daily callbacks
@@ -76,6 +76,10 @@ daily durable messages = daily actor writes + daily actor reads + daily callback
76
76
  Review the [retention requirements](operations.md#retention-and-backups) before
77
77
  adopting a high-volume surface.
78
78
 
79
+ Use `reference.snapshot` for an authorized current-state read when mailbox
80
+ ordering is unnecessary. It avoids a message row but can observe state before
81
+ an in-flight turn commits.
82
+
79
83
  ## Measure the host application
80
84
 
81
85
  Run the adoption benchmark against a dedicated empty database with the same
data/docs/correctness.md CHANGED
@@ -101,13 +101,46 @@ The following are atomic:
101
101
  - activation owner, expiration, and generation acquisition;
102
102
  - ready-to-claimed membership move and attempt increment;
103
103
  - state, state version, message result/completion, claimed deletion, effects,
104
- reminders, outbound actor messages, and observable broadcasts;
104
+ same-database commit actions, reminders, outbound actor messages, observable
105
+ broadcasts, and the monotonic actor state revision;
105
106
  - failed-attempt record plus ready reinsertion or dead letter;
106
107
  - effect completion plus its optional actor outcome message;
107
108
  - reminder occurrence enqueue plus reminder advancement; and
108
109
  - actor destruction plus cascading removal of all actor-owned rows.
109
110
 
110
111
  Actor Ruby code and external I/O are never inside the actor-state transaction.
112
+ Rails write prevention rejects direct Active Record writes while handlers,
113
+ observables, lifecycle hooks, or state migrations run. A registered commit
114
+ action is the only application-record write path inside the fenced commit, and
115
+ it is available only when Solid Objects and `ActiveRecord::Base` share one
116
+ connection pool. Commit actions must contain only bounded database work.
117
+ External I/O belongs in the effect outbox.
118
+
119
+ ## Reactive components
120
+
121
+ A successful fenced turn advances `instances.state_revision` to that message's
122
+ per-actor sequence and inserts changed-observable broadcast rows in the same
123
+ transaction. A rollback, retryable handler failure, or lost activation advances
124
+ neither. Component HTML is not durable and is never placed on the shared actor
125
+ stream.
126
+
127
+ Each component token signs the actor identity, conventional component name,
128
+ explicit dependencies, initial instance ID and revision, and same-origin
129
+ refresh path. The signature detects modification but grants no access. Initial
130
+ rendering invokes query authorization, Cable separately invokes subscription
131
+ authorization, and the cookie-bearing refresh request invokes query
132
+ authorization again for the component name and every dependency.
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.
137
+
138
+ Cable compares `(instance_id, state_revision)` pairs, coalesces dependencies
139
+ changed by the same turn, and ignores an older pair after a newer one. A new
140
+ invalidation replaces the whole Turbo Frame generation. A response owned by
141
+ the detached older frame cannot overwrite the current frame. Reconnect
142
+ compares the component's signed initial pair with the current instance row and
143
+ requests the latest committed snapshot when stale.
111
144
 
112
145
  ## Synchronous invocation
113
146
 
@@ -119,6 +152,17 @@ result. A worker may win the activation instead; the caller then observes the
119
152
  durable result through wake-up hints with bounded polling as fallback.
120
153
 
121
154
  Timeout raises `SolidObjects::SyncTimeout` but does not cancel the message.
155
+ The exception reports actor identity, message ID and sequence, durable status,
156
+ an earlier mailbox blocker, and activation-owner metadata without exposing
157
+ arguments. Its `message_reference` can reauthorize and wait for the eventual
158
+ result. Adapter lock/query deadlines cover the durable enqueue and coordination
159
+ transactions. If enqueue cannot commit, `SyncEnqueueTimeout` is raised and no
160
+ message reference exists. MySQL lock waits have one-second InnoDB granularity.
161
+ Ruby handlers that already started are not preempted.
162
+
163
+ A synchronous call made while the Solid Objects connection already has an open
164
+ transaction raises `SolidObjects::SyncInsideTransaction` before the message is
165
+ enqueued.
122
166
  Destroying the actor while a synchronous caller waits removes its message,
123
167
  wakes the caller, and raises `SolidObjects::ActorDestroyed`.
124
168
 
@@ -8,9 +8,15 @@ migrations load. No partial indexes are used.
8
8
  ### `instances`
9
9
 
10
10
  One row per `(actor_type, actor_id)`. Stores JSON state, state version,
11
- next-message sequence, activation owner/token/expiration/generation, pause
12
- state, and lifecycle timestamps. The owner/token pairing is constrained so one
13
- process row cannot make two concurrent activations appear identical.
11
+ monotonic state revision, next-message sequence, activation
12
+ owner/token/expiration/generation, pause state, and lifecycle timestamps. The
13
+ owner/token pairing is constrained so one process row cannot make two
14
+ concurrent activations appear identical.
15
+
16
+ `state_revision` advances to the successful message sequence in the same
17
+ fenced transaction as state and outboxes. Reactive components compare
18
+ `(instance_id, state_revision)` so pruned message history cannot make revisions
19
+ regress and destroy-and-recreate produces a new incarnation.
14
20
 
15
21
  Deleting an instance is the actor-incarnation boundary. Foreign keys cascade
16
22
  the delete through messages, ready and claimed memberships, reminders, effects,
@@ -24,6 +30,11 @@ Indexes:
24
30
  - owner: dead-process cleanup;
25
31
  - last used/ID: retention and reconciliation.
26
32
 
33
+ Instance expiration is disabled unless its actor type has an explicit
34
+ retention policy. The pruner uses the last-used index for candidate discovery,
35
+ then locks and rechecks that the actor has no owner, mailbox membership,
36
+ scheduled reminder, unresolved outbox, or dead letter before deletion.
37
+
27
38
  ### `messages`
28
39
 
29
40
  Durable immutable invocation identity and arguments plus sequence, attempt
@@ -80,8 +91,10 @@ Status/availability/ID drives delivery; completion/ID drives cleanup.
80
91
  ### `broadcasts`
81
92
 
82
93
  Durable observable-change outbox. The unique message/observable key prevents
83
- duplicate rows for one actor turn. Claim and delivery indexes support retries
84
- and cleanup.
94
+ duplicate rows for one actor turn. Rows contain the observable JSON value and
95
+ message/instance references used to derive invalidation metadata, never
96
+ personalized rendered HTML. Claim and delivery indexes support retries and
97
+ cleanup.
85
98
 
86
99
  ### `dead_letters`
87
100
 
data/docs/development.md CHANGED
@@ -28,6 +28,46 @@ SOLID_OBJECTS_DATABASE_URL=mysql2://... bundle exec rake test
28
28
  Each database run must start from an empty dedicated test database because the
29
29
  test helper applies the engine migration.
30
30
 
31
+ ## Host application tests
32
+
33
+ Rails transactional tests keep the application connection inside an outer
34
+ transaction. Synchronous actor invocation deliberately rejects that condition
35
+ because nested savepoints retain actor locks until the test transaction ends
36
+ and make durable behavior unlike production.
37
+
38
+ Use an actor-specific base class:
39
+
40
+ ```ruby
41
+ require "solid_objects/test_helper"
42
+
43
+ class SolidObjectsTestCase < ActiveSupport::TestCase
44
+ include SolidObjects::TestHelper
45
+ end
46
+ ```
47
+
48
+ The helper disables transactional tests for that class and removes Solid
49
+ Objects instances and process registrations before and after each test. It
50
+ preserves application configuration, actor registration, and effect/commit
51
+ action registration. If actor commit actions create application records, clean
52
+ those records with fixtures or explicit teardown because they are no longer
53
+ covered by Rails' transaction rollback.
54
+
55
+ Use `drain_solid_objects` to process actor, reminder, effect, callback, and
56
+ broadcast work to a deterministic fixed point without arbitrary sleeps:
57
+
58
+ ```ruby
59
+ message = Counter.ref("test").async(:increment)
60
+
61
+ assert_equal 1, drain_solid_objects
62
+ assert_equal "completed", message.status
63
+ ```
64
+
65
+ Pass `roles: [:actors]` when a test intentionally wants to leave outboxes or
66
+ reminders pending.
67
+
68
+ `SolidObjects::TestHelper.reset_actors!` is also available for explicit suite
69
+ boundaries.
70
+
31
71
  ## Inline RBS
32
72
 
33
73
  Ruby source starts with:
data/docs/fit.md CHANGED
@@ -65,11 +65,19 @@ Each application process that performs synchronous calls registers one caller
65
65
  process row. Actor state is rewritten as a JSON value on each successful
66
66
  mutation.
67
67
 
68
- There is no built-in retention command in 0.2. Completed message history grows
69
- until the host application implements a reviewed retention policy. See
68
+ Built-in bounded pruning is explicit and dry-run by default. Configure global
69
+ and per-actor-type message retention, then schedule the reviewed execute
70
+ commands. Actor instances expire only for types explicitly listed in
71
+ `instance_retention_by_actor_type`. See
70
72
  [performance measurements](benchmarks.md) and
71
73
  [retention guidance](operations.md#retention-and-backups).
72
74
 
75
+ Actor handlers may read application records but may not write them directly.
76
+ Use a same-database commit action for a short atomic database change or an
77
+ idempotent effect for external work. If the domain needs broad relational
78
+ updates throughout arbitrary handler code, an ordinary Active Record service
79
+ is likely a clearer fit.
80
+
73
81
  ## Decision checklist
74
82
 
75
83
  Before adopting an actor, answer:
@@ -101,6 +101,12 @@ If the actor becomes authoritative before the legacy system is retired, emit a
101
101
  transactional effect that updates the legacy store. The effect is at least once,
102
102
  so the legacy write still needs idempotency.
103
103
 
104
+ When the legacy state is an application table in the same connection pool as
105
+ Solid Objects, a registered `commit_action` can update it in the fenced actor
106
+ transaction instead. Keep that action database-only and bounded. A separate
107
+ actor database cannot make this atomic; use the outbox effect and reconciliation
108
+ path there.
109
+
104
110
  ## 5. Cut over in reversible stages
105
111
 
106
112
  A typical zero-downtime sequence is:
@@ -119,7 +125,8 @@ A typical zero-downtime sequence is:
119
125
 
120
126
  Use a feature flag whose rollback restores legacy reads and writes without
121
127
  requiring actor deletion. Do not assume a timed-out synchronous actor call did
122
- not commit; query the durable result or use an idempotency key before retrying.
128
+ not commit; use `error.message_reference.wait` to reauthorize and recover the
129
+ durable result, or use an idempotency key before retrying.
123
130
 
124
131
  ## 6. Plan for dormant state and future changes
125
132