solid_objects 0.2.1 → 0.3.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +129 -8
  4. data/docs/architecture.md +62 -15
  5. data/docs/authorization.md +16 -3
  6. data/docs/benchmarks.md +6 -2
  7. data/docs/correctness.md +19 -1
  8. data/docs/database-schema.md +5 -0
  9. data/docs/development.md +40 -0
  10. data/docs/fit.md +10 -2
  11. data/docs/migrating-existing-state.md +8 -1
  12. data/docs/operations.md +74 -36
  13. data/docs/roadmap.md +8 -2
  14. data/docs/security.md +25 -3
  15. data/docs/state-migrations.md +2 -0
  16. data/lib/generators/solid_objects/templates/solid_objects.rb +30 -2
  17. data/lib/solid_objects/activation.rb +33 -4
  18. data/lib/solid_objects/actor.rb +47 -6
  19. data/lib/solid_objects/actor_definition.rb +2 -0
  20. data/lib/solid_objects/actor_snapshot.rb +10 -4
  21. data/lib/solid_objects/application_write_guard.rb +24 -0
  22. data/lib/solid_objects/caller_process.rb +28 -0
  23. data/lib/solid_objects/cli.rb +44 -5
  24. data/lib/solid_objects/client.rb +98 -5
  25. data/lib/solid_objects/commit_action_registry.rb +42 -0
  26. data/lib/solid_objects/configuration.rb +27 -1
  27. data/lib/solid_objects/database_adapter.rb +28 -1
  28. data/lib/solid_objects/database_adapters/mysql.rb +46 -0
  29. data/lib/solid_objects/database_adapters/postgresql.rb +29 -0
  30. data/lib/solid_objects/database_adapters/sqlite.rb +28 -0
  31. data/lib/solid_objects/errors.rb +144 -0
  32. data/lib/solid_objects/executor.rb +64 -4
  33. data/lib/solid_objects/instance_pruner.rb +97 -0
  34. data/lib/solid_objects/message_pruner.rb +97 -0
  35. data/lib/solid_objects/message_reference.rb +9 -0
  36. data/lib/solid_objects/process_pruner.rb +49 -0
  37. data/lib/solid_objects/reference.rb +5 -0
  38. data/lib/solid_objects/state_snapshot.rb +41 -0
  39. data/lib/solid_objects/sync_deadline.rb +57 -0
  40. data/lib/solid_objects/sync_diagnostics.rb +133 -0
  41. data/lib/solid_objects/synchronous_invocation.rb +26 -7
  42. data/lib/solid_objects/test_helper.rb +78 -0
  43. data/lib/solid_objects/version.rb +1 -1
  44. data/lib/solid_objects/worker.rb +1 -1
  45. data/lib/solid_objects.rb +34 -0
  46. data/sig/generated/lib/solid_objects/activation.rbs +3 -0
  47. data/sig/generated/lib/solid_objects/actor.rbs +26 -0
  48. data/sig/generated/lib/solid_objects/application_write_guard.rbs +8 -0
  49. data/sig/generated/lib/solid_objects/caller_process.rbs +11 -0
  50. data/sig/generated/lib/solid_objects/cli.rbs +11 -2
  51. data/sig/generated/lib/solid_objects/client.rbs +15 -0
  52. data/sig/generated/lib/solid_objects/commit_action_registry.rbs +43 -0
  53. data/sig/generated/lib/solid_objects/configuration.rbs +27 -7
  54. data/sig/generated/lib/solid_objects/database_adapter.rbs +9 -0
  55. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +8 -0
  56. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
  57. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +8 -0
  58. data/sig/generated/lib/solid_objects/errors.rbs +118 -0
  59. data/sig/generated/lib/solid_objects/executor.rbs +12 -0
  60. data/sig/generated/lib/solid_objects/instance_pruner.rbs +36 -0
  61. data/sig/generated/lib/solid_objects/message_pruner.rbs +42 -0
  62. data/sig/generated/lib/solid_objects/message_reference.rbs +3 -0
  63. data/sig/generated/lib/solid_objects/process_pruner.rbs +27 -0
  64. data/sig/generated/lib/solid_objects/reference.rbs +3 -0
  65. data/sig/generated/lib/solid_objects/state_snapshot.rbs +30 -0
  66. data/sig/generated/lib/solid_objects/sync_deadline.rbs +31 -0
  67. data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +34 -0
  68. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +3 -0
  69. data/sig/generated/lib/solid_objects/test_helper.rbs +25 -0
  70. data/sig/generated/lib/solid_objects.rbs +12 -0
  71. metadata +19 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 654720ae19caa970f403b5cded61287e1f6a9eb9d731ff90fd9f4f103d55fa53
4
- data.tar.gz: 256712423dd88685643a0345de44eb774efbca12188c78c9e89571ef884b59fc
3
+ metadata.gz: 48689af6e9b4f08415ddfd549a99d4e7e667e67db5aa4e92bc0052a6d97f51af
4
+ data.tar.gz: 82dc95dc346daf7260356996bfadc99c9c8dd713f2c0fa8c5d8f5efe7859021a
5
5
  SHA512:
6
- metadata.gz: f1e43da5f13c33558bdb187b52c55b55093786d3ec8af9f49d4ffc123ade274d2f5483dd8975c6a638152dfc9ae1c4cf416d8b1ea94cb1383ac30b33ef7548aa
7
- data.tar.gz: f8cb10850b5b3748450ffaf741417866ed360437657c5d21836312018f0d6b27109cd69c4035f770f6cf643227ab086ae7eedc1b0a14245103cfe517a008f49c
6
+ metadata.gz: d3a4ce2860cca8656919193b2a9bb6b2c2367552653af106db5fd065bd2bf3c7339a543c5b1bc1fdf1bb7d322e88639a6f20cdba2b5cf551b406e629e6d31598
7
+ data.tar.gz: '086652b090e80b0c6830b269e8857ef6de1944b2c2ab145340eeb4a8ba97a26875b9a612c5105ae46bff7362477fe20c3abde837aa4b87d27f5fb91bf19e316e'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-08-06
4
+
5
+ - Reject application-record writes from actor handlers and provide registered
6
+ same-database commit actions for fenced atomic changes.
7
+ - Reject synchronous invocation inside an open Solid Objects transaction and
8
+ add adapter database deadlines, durable diagnostics, and recoverable results
9
+ to sync timeouts.
10
+ - Guard handlers, observables, lifecycle hooks, and state migrations from
11
+ direct application-record writes.
12
+ - Add dry-run-first bounded message, process, and opt-in actor-instance
13
+ pruning, configurable retention, and graceful caller-process shutdown.
14
+ - Add authorized committed state snapshots, mutable JSON copies, commit-action
15
+ instrumentation, and deterministic full-runtime Minitest draining.
16
+
3
17
  ## 0.2.1 - 2026-08-06
4
18
 
5
19
  - Add `solid_objects:doctor` for configuration, schema, policy, runtime, and
data/README.md CHANGED
@@ -24,6 +24,7 @@ end
24
24
  counter = Counter.ref("global")
25
25
  count = counter.increment(amount: 5)
26
26
  current_count = counter.value
27
+ current_snapshot = counter.snapshot.value
27
28
 
28
29
  # Durable fire-and-forget delivery. A worker processes it later.
29
30
  message = counter.async(:increment, amount: 5)
@@ -41,13 +42,21 @@ The invocation model is the first adoption decision:
41
42
  | --- | --- | --- |
42
43
  | `counter.increment(amount: 5)` | Committed handler result | No |
43
44
  | `counter.sync(:increment, amount: 5)` | Committed handler result | No |
44
- | `counter.value` | Deeply frozen state snapshot | No |
45
+ | `counter.value` | Ordered, committed query result | No |
46
+ | `counter.snapshot.value` | Current committed state without a mailbox message | No |
45
47
  | `counter.async(:increment, amount: 5)` | `MessageReference` immediately | Yes |
46
48
 
47
49
  Direct methods and `sync` durably enqueue the call, then the Rails caller helps
48
50
  execute the actor through the same mailbox, lease, and fencing path as a
49
51
  worker. `async` only enqueues; a runtime process handles it later.
50
52
 
53
+ Synchronous calls fail before enqueue when the Solid Objects database
54
+ connection is already inside a transaction. Actor handlers may read application
55
+ records, but direct Active Record writes are rejected so they cannot escape a
56
+ later actor failure. Use a same-database
57
+ [`commit_action`](#application-database-writes) for atomic database changes and
58
+ [`emit`](#effects) for external I/O.
59
+
51
60
  Before adopting a latency-sensitive or high-volume surface, read
52
61
  [Is Solid Objects a good fit?](docs/fit.md) and the
53
62
  [measured performance and row-growth costs](docs/benchmarks.md).
@@ -56,8 +65,8 @@ This is a port of the programming model, not Cloudflare's edge runtime or
56
65
  platform. Read the conceptual overview at [solidobjects.dev](https://solidobjects.dev/)
57
66
  and the exact Rails guarantees in [Correctness and delivery semantics](docs/correctness.md).
58
67
 
59
- Version 0.2 is an early release. Its correctness core is implemented and tested,
60
- but the project does not yet claim production readiness. See
68
+ Solid Objects is an early release. Its correctness core is implemented and
69
+ tested, but the project does not yet claim production readiness. See
61
70
  [Status](#status) and the [roadmap](docs/roadmap.md).
62
71
 
63
72
  ## Table of contents
@@ -69,6 +78,7 @@ but the project does not yet claim production readiness. See
69
78
  - [Defining an actor](#defining-an-actor)
70
79
  - [Actor identity](#actor-identity)
71
80
  - [Invoking an object](#invoking-an-object)
81
+ - [Application database writes](#application-database-writes)
72
82
  - [Effects](#effects)
73
83
  - [Reminders](#reminders)
74
84
  - [Destroying an object](#destroying-an-object)
@@ -194,7 +204,7 @@ refresh from current actor state.
194
204
  `cart.component(:summary)` supports initial rendering of
195
205
  `actors/shopping_cart/_summary`. Durable live component replacement and
196
206
  Turbo append actions are roadmap work; observable replacement is the live path
197
- implemented in 0.2.
207
+ implemented today.
198
208
 
199
209
  Reactive views require `turbo-rails` and a working Action Cable adapter in the
200
210
  host application. They are optional; the actor runtime itself does not depend
@@ -273,6 +283,7 @@ the runtime when the feature introduces asynchronous delivery or outboxes:
273
283
  | --- | --- |
274
284
  | Direct actor method or explicit `sync` | None; the caller executes it |
275
285
  | Attribute or declared query read | None; the caller executes it |
286
+ | Committed `snapshot` read | None; reads the instance row directly |
276
287
  | `destroy` | None |
277
288
  | `async` including delayed delivery | Actor worker |
278
289
  | One-shot or recurring `schedule` | Reminder scheduler and actor worker |
@@ -346,6 +357,20 @@ mailbox. State changes must go through public actor methods or explicit
346
357
  State, arguments, results, effects, and reminder arguments accept
347
358
  JSON-compatible values. Solid Objects never deserializes Ruby `Marshal` data.
348
359
 
360
+ Attribute readers are ordered mailbox queries and retain message history. For
361
+ a read that does not need mailbox ordering, use an authorized committed
362
+ snapshot:
363
+
364
+ ```ruby
365
+ snapshot = cart.snapshot
366
+ items = snapshot.items
367
+ ```
368
+
369
+ Snapshots and synchronous results are deeply frozen. Use
370
+ `SolidObjects.mutable_copy(items)` before changing a returned collection.
371
+ Snapshot reads can race with an in-flight turn; they return the most recently
372
+ committed state and do not create or activate a missing actor.
373
+
349
374
  Lifecycle hooks are also available:
350
375
 
351
376
  ```ruby
@@ -451,6 +476,37 @@ and MCP request/response boundaries when the handler itself fits the
451
476
  application's latency budget. If another process owns the activation, the
452
477
  caller waits for the durable result using wake-up hints with bounded database
453
478
  polling as the fallback. A timeout never cancels the durable invocation.
479
+ `SolidObjects::SyncTimeout` includes actor identity, message ID, sequence,
480
+ durable status, mailbox blocker, and activation-owner diagnostics without
481
+ including message arguments. The configured timeout also bounds adapter
482
+ database lock waits from the enqueue attempt through result observation.
483
+ PostgreSQL uses transaction lock and statement timeouts, SQLite uses its busy
484
+ timeout, and MySQL uses its execution timeout plus InnoDB's one-second minimum
485
+ lock-wait granularity.
486
+
487
+ The durable call can finish after its original caller gives up. Reauthorize and
488
+ recover its eventual result through the durable message identity:
489
+
490
+ ```ruby
491
+ begin
492
+ order.submit(timeout: 250.milliseconds)
493
+ rescue SolidObjects::SyncTimeout => error
494
+ result = error.message_reference.wait(
495
+ timeout: 5.seconds,
496
+ authorization_context: Current.user
497
+ )
498
+ end
499
+ ```
500
+
501
+ If the enqueue transaction itself cannot finish within the budget, Solid
502
+ Objects raises `SyncEnqueueTimeout`; no durable message exists to recover.
503
+ Timeouts do not preempt Ruby handler code that has already started.
504
+
505
+ Do not wrap a synchronous actor call in `ApplicationRecord.transaction`.
506
+ Solid Objects raises `SolidObjects::SyncInsideTransaction` before enqueue when
507
+ its connection already has an open transaction. Move the actor call before the
508
+ transaction, use `async`, or let the actor own the coordinated change through a
509
+ commit action.
454
510
 
455
511
  Actor code cannot use direct calls or `sync` on another actor; synchronous
456
512
  actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
@@ -492,6 +548,50 @@ end
492
548
 
493
549
  External systems must also deduplicate effects using the stable effect ID.
494
550
 
551
+ ## Application database writes
552
+
553
+ Actor handlers execute outside the fenced commit. They may query application
554
+ records, but Solid Objects rejects direct Active Record writes from all
555
+ user-supplied actor code: handlers, observables, activation/deactivation hooks,
556
+ and state migrations. Otherwise an application row could commit before the
557
+ actor later raises or loses its activation fence.
558
+
559
+ For a short database-only change that must commit atomically with actor state,
560
+ stage a named action:
561
+
562
+ ```ruby
563
+ class Assessment < SolidObjects::Actor
564
+ attribute :status, default: "open"
565
+
566
+ def finish(attempt_id:, score:)
567
+ self.status = "complete"
568
+ commit_action :complete_attempt, attempt_id:, score:
569
+ end
570
+ end
571
+ ```
572
+
573
+ Register its implementation during application boot:
574
+
575
+ ```ruby
576
+ SolidObjects.register_commit_action(:complete_attempt) do |arguments, context|
577
+ AssessmentAttempt.find(arguments.fetch("attempt_id")).update!(
578
+ score: arguments.fetch("score"),
579
+ actor_message_id: context.message_id
580
+ )
581
+ end
582
+ ```
583
+
584
+ The registered block runs inside the short fenced transaction. Its database
585
+ writes, actor state, message completion, and outboxes all commit or roll back
586
+ together. Commit actions require Solid Objects and `ActiveRecord::Base` to
587
+ share one connection pool. They may be invoked again after a database rollback,
588
+ so keep them deterministic, bounded, and database-only. Never perform network
589
+ I/O, wait for another actor, or enqueue nontransactional work from a commit
590
+ action.
591
+
592
+ When Solid Objects uses a separate actor database, use `emit` and an idempotent
593
+ effect consumer instead; the two databases cannot share one transaction.
594
+
495
595
  ## Effects
496
596
 
497
597
  Cloudflare Durable Objects can call external services directly. Solid Objects
@@ -555,6 +655,10 @@ It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
555
655
  `.orphaned`, but every repair must go through `async`. Never bulk-update actor
556
656
  state around the lease and fencing checks.
557
657
 
658
+ Suspended actors should be reported rather than silently resumed. Spread large
659
+ repair batches with `available_at:` so reconciliation cannot stampede one
660
+ mailbox or the worker fleet.
661
+
558
662
  ## Destroying an object
559
663
 
560
664
  Destroy an actor incarnation through its reference:
@@ -631,6 +735,11 @@ Important defaults:
631
735
  | `max_attempts` | 5 |
632
736
  | `process_heartbeat_interval` | 15 seconds |
633
737
  | `process_alive_threshold` | 60 seconds |
738
+ | `message_retention` | 30 days |
739
+ | `message_retention_by_actor_type` | `{}` |
740
+ | `instance_retention_by_actor_type` | `{}`; instances never expire unless listed |
741
+ | `process_retention` | 7 days |
742
+ | `prune_batch_size` | 1,000 |
634
743
  | `worker_count` | 1 |
635
744
  | `effect_worker_count` | 1 |
636
745
  | `broadcast_worker_count` | 1 |
@@ -664,10 +773,16 @@ Administration commands require the administration policy:
664
773
  ```bash
665
774
  bundle exec solid_objects status
666
775
  bundle exec solid_objects cleanup
776
+ bundle exec solid_objects prune_messages
777
+ bundle exec solid_objects prune_instances
778
+ bundle exec solid_objects prune_processes
667
779
  bundle exec solid_objects dead_letters
668
780
  bundle exec solid_objects retry_dead_letter 123
669
781
  ```
670
782
 
783
+ The prune commands preview counts by default. Add `--execute` only after
784
+ reviewing the configured retention policy.
785
+
671
786
  The supervisor stops new claims, drains active loops, releases cached leases,
672
787
  and marks process rows stopped on graceful shutdown. A hard-killed worker's
673
788
  claimed turn is recovered after its process heartbeat or activation lease
@@ -814,11 +929,13 @@ See the [development guide](docs/development.md) and
814
929
 
815
930
  ## Status
816
931
 
817
- Implemented and tested in 0.2:
932
+ Implemented and tested in 0.3:
818
933
 
819
934
  - Rails engine, install generator, migrations, and `solid_objects` executable;
820
935
  - actor registry, references, JSON state, and state migrations;
821
936
  - direct synchronous actor RPC, explicit `sync`, and durable `async`;
937
+ - guarded transaction boundaries, same-database commit actions, adapter lock
938
+ deadlines, structured synchronous timeout diagnostics, and result recovery;
822
939
  - durable message history plus ready and claimed membership tables;
823
940
  - concurrent sequence allocation and actor creation;
824
941
  - activation leases, per-activation tokens, fencing generations, and
@@ -831,7 +948,11 @@ Implemented and tested in 0.2:
831
948
  - authorized actor destruction with fenced stale-write rejection and cascading
832
949
  durable-work cleanup;
833
950
  - durable observable broadcasts and authorized Action Cable refresh;
834
- - process registration, heartbeats, cleanup, and graceful shutdown; and
951
+ - process registration, heartbeats, caller shutdown, cleanup, and bounded
952
+ message/process retention plus opt-in actor-instance expiration;
953
+ - an opt-in Minitest helper for actor-state isolation and deterministic async
954
+ actor/reminder/effect/broadcast draining;
955
+ - authorized mailbox-free state snapshots and mutable JSON copies; and
835
956
  - SQLite, PostgreSQL, and MySQL integration tests.
836
957
 
837
958
  Partially implemented:
@@ -844,8 +965,8 @@ Partially implemented:
844
965
  Turbo append actions remain future work;
845
966
  - local admission limits exist, but distributed rate limits and global
846
967
  admission control do not; and
847
- - administration views exist, but retention automation and richer audit tools
848
- do not.
968
+ - administration views and pruning commands exist, but scheduled maintenance
969
+ and richer audit tools do not.
849
970
 
850
971
  Production readiness requires hardening and operational soak evidence. The
851
972
  [roadmap](docs/roadmap.md) tracks that work.
data/docs/architecture.md CHANGED
@@ -150,6 +150,12 @@ snapshots are deeply frozen. Use `async` for durable fire-and-forget delivery
150
150
  and `sync` for dynamic operation names. The explicit `message` DSL remains
151
151
  available for dynamic definitions.
152
152
 
153
+ `reference.snapshot` is the explicit unordered read path. It invokes query
154
+ authorization, reads the most recently committed instance state without
155
+ creating a message or activation, applies required state migrations in memory,
156
+ and returns deeply frozen declared attributes. It can race with an in-flight
157
+ turn. `SolidObjects.mutable_copy` creates an independent mutable JSON value.
158
+
153
159
  `message` and `query` both execute as durable mailbox turns. A query may not
154
160
  mutate state. The executor detects query mutation and fails the message. An
155
161
  observable is a named projection of state used by server rendering and realtime
@@ -161,6 +167,8 @@ Lifecycle hooks are deterministic local hooks:
161
167
  - `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
168
 
163
169
  Durable application cleanup belongs in messages, reminders, or effects.
170
+ Handlers, observable blocks, lifecycle hooks, and state migrations all execute
171
+ with application Active Record writes prevented.
164
172
 
165
173
  ## Enqueue and sequence allocation
166
174
 
@@ -248,6 +256,8 @@ Actor code then executes with no open database transaction and no pinned connect
248
256
 
249
257
  - Read and mutate its in-memory state for a message
250
258
  - Read state for a query
259
+ - Read application records
260
+ - Stage same-database commit actions
251
261
  - Stage effects
252
262
  - Stage reminders
253
263
  - Stage asynchronous actor messages
@@ -258,8 +268,16 @@ It cannot:
258
268
  - Perform a synchronous actor-to-actor wait
259
269
  - Assume execution happens once
260
270
  - Commit actor state directly
271
+ - Write application records directly
272
+ - Perform external I/O that must be atomic with actor state
261
273
 
262
- After actor code, the executor validates state and staged data as JSON and computes changed observables.
274
+ Rails write prevention turns a direct Active Record write into
275
+ `ApplicationWriteForbidden` before it reaches the database. Handler and
276
+ observable failures become nonretryable message failures. Activation and
277
+ migration failures happen before the message is claimed. A deactivation-hook
278
+ failure is instrumented and logged, but cannot replace an already committed
279
+ result or prevent best-effort lease release. After actor code, the executor
280
+ validates state and staged data as JSON and computes changed observables.
263
281
 
264
282
  ## Fenced commit
265
283
 
@@ -268,14 +286,15 @@ Successful completion uses one database transaction:
268
286
  1. Lock the instance row.
269
287
  2. Verify owner, activation token, generation, and an unexpired lease using database time.
270
288
  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.
289
+ 4. Execute registered same-database commit actions.
290
+ 5. Update native JSON state and state version.
291
+ 6. Store the completion timestamp and result on the durable message and delete claimed membership.
292
+ 7. Insert staged effects.
293
+ 8. Insert or update staged reminders.
294
+ 9. Insert staged actor-message outbox rows.
295
+ 10. Insert changed-observable broadcast rows.
296
+ 11. Update actor last-used time.
297
+ 12. Commit.
279
298
 
280
299
  Any lease or message predicate failure raises `LostActivation` and rolls back every item. The stale worker discards its in-memory activation.
281
300
 
@@ -346,11 +365,25 @@ overhead at or below 100 milliseconds; polling fallback can pay up to
346
365
  Caller timeout:
347
366
 
348
367
  - Raises `SolidObjects::SyncTimeout`.
368
+ - Reports durable message, blocker, and activation-owner diagnostics.
369
+ - Exposes a `message_reference` that can reauthorize and wait again.
349
370
  - Does not cancel or delete the message.
350
371
  - Does not prevent later execution.
351
372
  - Leaves the result available until retention cleanup.
352
373
 
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.
374
+ The configured timeout begins before durable enqueue. PostgreSQL transaction
375
+ lock and statement timeouts, MySQL execution and InnoDB lock-wait timeouts, and
376
+ SQLite busy timeout bound database contention. MySQL rounds lock waits up to
377
+ its one-second minimum. If enqueue cannot commit, `SyncEnqueueTimeout` is
378
+ raised and no durable result exists. Once Ruby handler code starts, it is not
379
+ safely preempted and may outlive the caller budget.
380
+
381
+ The durable row remains after a normal wait timeout and can be recovered with
382
+ `error.message_reference.wait`. Bounded retention commands are implemented;
383
+ public request-ID lookup is not. A sync call made with an already-open
384
+ transaction on the Solid Objects connection raises `SyncInsideTransaction`
385
+ before enqueue. This avoids savepoint enlistment, lock retention until the
386
+ outer commit, and callers timing out on work they indirectly block.
354
387
 
355
388
  `async` performs the same durable enqueue without caller assistance or result
356
389
  waiting and immediately returns a `MessageReference`. Runtime workers process
@@ -505,6 +538,7 @@ end
505
538
  ```
506
539
 
507
540
  Activation applies each step in order. Missing steps, cycles, non-JSON output, or stored versions newer than code fail activation.
541
+ Migration code is subject to the same application-write guard as handlers.
508
542
 
509
543
  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
544
 
@@ -573,6 +607,19 @@ An activation becomes idle when it has no due earliest message and no turn in fl
573
607
  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
608
 
575
609
  `on_deactivate` is best effort and nondurable. It may not run on crash and cannot be the source of a correctness requirement.
610
+ A failing hook is logged and instrumented as
611
+ `solid_objects.activation.deactivation_failed`; lease release is still
612
+ attempted and a committed synchronous result is preserved.
613
+
614
+ ## Retention
615
+
616
+ Terminal messages and stopped process rows have configurable bounded pruning.
617
+ Actor instances never expire by default. Types listed in
618
+ `instance_retention_by_actor_type` become eligible after their idle cutoff only
619
+ when they are unowned, unpaused, and have no ready/claimed work, scheduled
620
+ reminder, unfinished or dead outbox, or dead letter. The pruner locks and
621
+ rechecks each candidate before cascading deletion. All pruning commands
622
+ preview by default and require administration authorization.
576
623
 
577
624
  ## Advisory locks
578
625
 
@@ -612,7 +659,7 @@ The semantic guarantees are common, but their coordination implementations diffe
612
659
  | JSON state | JSONB | JSON | Rails JSON type |
613
660
  | Executable-work indexes | Ready/claimed membership tables | Ready/claimed membership tables | Ready/claimed membership tables |
614
661
  | 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 |
662
+ | Sync contention deadline | Transaction lock and statement timeouts | Execution timeout and one-second-granularity InnoDB lock timeout | Busy timeout |
616
663
  | Lease clock | Database current time | Database current time | Database current time |
617
664
 
618
665
  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 +680,14 @@ All backends use unique identity and sequence constraints, short transactions, a
633
680
  12. **How are leases renewed?** Conditional database update by instance, owner, generation, and unexpired lease.
634
681
  13. **How does graceful shutdown work?** Stop claims, finish current turn within timeout, release cached leases, stop heartbeat, mark process stopped.
635
682
  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.
683
+ 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.
684
+ 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
685
  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.
686
+ 18. **How are completed messages pruned?** Operators schedule the dry-run-reviewed `prune_messages --execute` command. Solid Objects does not run deletion automatically.
640
687
  19. **How are state migrations performed?** Explicit one-step actor migrations on activation, persisted only with a successful fenced commit.
641
688
  20. **What happens during rolling deploys?** Newer state can make old workers incompatible; deploys must preserve backward readability or drain old workers.
642
689
  21. **How are subscriptions authorized?** Verify signed identity, resolve registered type, invoke host authorization, then stream.
643
690
  22. **How are lost broadcasts recovered?** Current-state refresh after reconnect; durable outbox retries server delivery.
644
691
  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.
692
+ 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
693
  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,12 +10,14 @@ 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, 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 |
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
 
21
23
  ## A tenant-aware policy
@@ -80,6 +82,17 @@ Keep `authorize_destroy`, `authorize_subscription`, and
80
82
  Replace unconditional policies before exposing actor IDs to controllers, API
81
83
  clients, MCP tools, jobs carrying user input, or browser subscriptions.
82
84
 
85
+ For commands executed only on hosts where shell access is already the
86
+ authenticated administration boundary, the generated initializer shows an
87
+ optional CLI-scoped policy:
88
+
89
+ ```ruby
90
+ configuration.authorize_administration = lambda do |authorization_context:, **|
91
+ authorization_context.is_a?(Hash) &&
92
+ authorization_context[:source] == "cli"
93
+ end
94
+ ```
95
+
83
96
  Run `bin/rails solid_objects:doctor` after configuration. Its neutral policy
84
97
  probe is deliberately conservative: a context-aware policy may correctly warn
85
98
  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,20 @@ 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, and
105
+ observable broadcasts;
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.
111
118
 
112
119
  ## Synchronous invocation
113
120
 
@@ -119,6 +126,17 @@ result. A worker may win the activation instead; the caller then observes the
119
126
  durable result through wake-up hints with bounded polling as fallback.
120
127
 
121
128
  Timeout raises `SolidObjects::SyncTimeout` but does not cancel the message.
129
+ The exception reports actor identity, message ID and sequence, durable status,
130
+ an earlier mailbox blocker, and activation-owner metadata without exposing
131
+ arguments. Its `message_reference` can reauthorize and wait for the eventual
132
+ result. Adapter lock/query deadlines cover the durable enqueue and coordination
133
+ transactions. If enqueue cannot commit, `SyncEnqueueTimeout` is raised and no
134
+ message reference exists. MySQL lock waits have one-second InnoDB granularity.
135
+ Ruby handlers that already started are not preempted.
136
+
137
+ A synchronous call made while the Solid Objects connection already has an open
138
+ transaction raises `SolidObjects::SyncInsideTransaction` before the message is
139
+ enqueued.
122
140
  Destroying the actor while a synchronous caller waits removes its message,
123
141
  wakes the caller, and raises `SolidObjects::ActorDestroyed`.
124
142
 
@@ -24,6 +24,11 @@ Indexes:
24
24
  - owner: dead-process cleanup;
25
25
  - last used/ID: retention and reconciliation.
26
26
 
27
+ Instance expiration is disabled unless its actor type has an explicit
28
+ retention policy. The pruner uses the last-used index for candidate discovery,
29
+ then locks and rechecks that the actor has no owner, mailbox membership,
30
+ scheduled reminder, unresolved outbox, or dead letter before deletion.
31
+
27
32
  ### `messages`
28
33
 
29
34
  Durable immutable invocation identity and arguments plus sequence, attempt
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