Skip to content

Commit 3d615c5

Browse files
committed
docs: name Solid Objects a virtual actor library
An agent asked "does Ruby have a virtual actor library" answered no. The architecture guide already used that category, but the README, the gem metadata, and the website did not. A baseline of 80 agent runs found the project in 15 of 64 suitable runs. Name the category where people and agents look first: - The README opening and the gemspec summary and description call Solid Objects a SQL-backed virtual actor library for Ruby on Rails. - The gem homepage links to https://solidobjects.dev/ruby. The site root redirects to the Node page. - docs/virtual-actors.md answers the category question. It has the definition, an example, fit and poor-fit criteria, comparisons with checked sources, and an Orleans concept map. - docs/agents.md gives coding agents setup, authorization, effect idempotency, verification, and troubleshooting steps. - context7.json limits Context7 to the consumer documentation. Add examples/quickstart and `rake quickstart`. The check builds the gem, installs it into a new Rails app from vendor/cache, and proves by checksum and load path that the app loads the built gem. It then proves that eight concurrent holds give one winner, and that a due reminder waits while the runtime is stopped and runs after a restart. It fails when a TicketSale sample in the README or docs/ drifts from the actor that it runs. CI runs it, and the release job waits for it. Correct docs/operations.md: json 3.0.2 breaks only Active Support 8.1.3.1 and earlier. Active Support 8.1.4 decodes correctly.
1 parent 51692a6 commit 3d615c5

14 files changed

Lines changed: 1286 additions & 8 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ jobs:
1818
- run: bundle exec rake
1919
- run: bundle exec rake at_least_once
2020

21+
quickstart:
22+
runs-on: ubuntu-latest
23+
timeout-minutes: 15
24+
steps:
25+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
26+
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
27+
with:
28+
ruby-version: "3.3"
29+
bundler-cache: true
30+
- run: bundle exec rake quickstart
31+
2132
javascript:
2233
runs-on: ubuntu-latest
2334
timeout-minutes: 15
@@ -148,6 +159,7 @@ jobs:
148159
if: startsWith(github.ref, 'refs/tags/v')
149160
needs:
150161
- sqlite
162+
- quickstart
151163
- postgresql
152164
- mysql
153165
- static

‎AGENTS.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ git push origin v0.5.0
5353

5454
CI validates the tag and publishes through RubyGems trusted publishing.
5555

56+
Before you tag, read `docs/virtual-actors.md` and `docs/agents.md` against the
57+
release and correct any requirement, compatibility, or guarantee statement that
58+
changed. After the tag publishes, refresh the solidobjects.dev documentation
59+
snapshot from the tag and redeploy the site; its `check:release` step refuses a
60+
snapshot that is not the latest published tag. Then trigger a Context7 refresh
61+
for this repository.
62+
5663
## Security & Configuration
5764

5865
Preserve deny-by-default authorization. Never treat actor IDs, stream names, or signed tokens as authorization, and never commit secrets or unsafe deserialization paths.

‎CHANGELOG.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,36 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Name the category in the gem metadata and the README: Solid Objects is a
6+
SQL-backed virtual actor library for Ruby on Rails. The gem homepage now
7+
links to `https://solidobjects.dev/ruby` instead of the site root, which
8+
redirects to the Node page.
9+
- Add `docs/virtual-actors.md`, a category guide with the definition, a small
10+
example, fit and poor-fit criteria, comparisons, and an Orleans concept map.
11+
- Add `docs/agents.md`, a consumer guide for coding agents with setup,
12+
authorization, effect idempotency, verification, and troubleshooting steps.
13+
Both guides ship in the gem.
14+
- Add `context7.json` so that Context7 indexes the consumer documentation and
15+
skips maintainer files.
16+
- Add a Rails quickstart in `examples/quickstart/` and a `rake quickstart`
17+
check that runs it against the built gem. The check builds the gem, creates
18+
a new SQLite Rails application, installs the gem from `vendor/cache` with
19+
`bundle install --local`, and confirms by checksum and load path that the
20+
application loads the built gem. It runs the install generator, the
21+
migrations, and the doctor, and grants only the message and query policies.
22+
It sends eight concurrent holds from separate processes to the README's
23+
`TicketSale` actor and confirms that exactly one hold commits. It stops the
24+
runtime, waits until a reminder is past due, confirms that the reminder did
25+
not run, restarts the runtime, and confirms that the reminder released the
26+
hold once. The check also fails when a `TicketSale` sample in the README or
27+
in `docs/` differs from the actor that it runs. A new `quickstart` CI job runs
28+
the check, and the release job waits for it.
29+
- Correct the `json` 3.0.2 note in `docs/operations.md`. Only Active Support
30+
8.1.3.1 and earlier 8.1 releases fail with it; Active Support 8.1.4 decodes
31+
correctly. Upgrade Rails, or pin `json` to 2.x when an upgrade is not
32+
possible.
33+
334
## 0.17.0 - 2026-10-03
435

536
- Publish RBS types for portable events, metric samples, actor diagnostics, and

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55

66
**Open Source Durable Objects in your Rails app.**
77

8+
Solid Objects is a SQL-backed virtual actor library for Ruby on Rails, with
9+
durable state, ordered operations, and automatic activation. Each actor has a
10+
stable identity, and its state lives in the SQL database that your app already
11+
uses. [Virtual actors in Ruby on Rails](docs/virtual-actors.md) explains the
12+
model and when to use it.
13+
814
In a shopping cart, paying twice at the same time is a big problem. The payment provider might time out, and your Rails site could be restarting before recovery finishes.
915

1016
To deal with this safely, you often need logic scattered between 7-10 files like database row locks, Redis locks, delayed jobs, retries, and cleanup code to keep that process straight. They are not all large, but they must agree about the same payment state and failure rules. That coordination is the difficult part.
@@ -160,6 +166,8 @@ Exactly once is not hiding in a more advanced configuration. Read the
160166
## Read more
161167

162168
- [Five-minute Rails guide](https://solidobjects.dev/5min/rails)
169+
- [Virtual actors in Ruby on Rails](docs/virtual-actors.md)
170+
- [Guide for coding agents](docs/agents.md)
163171
- [Choosing Solid Objects](docs/fit.md)
164172
- [Operations and recovery](docs/operations.md)
165173
- [Observability and diagnostics](docs/observability.md)

‎Rakefile‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ task :at_least_once do
3737
sh "bundle exec ruby examples/at_least_once/demo.rb"
3838
end
3939

40+
desc "Install the built gem into a new Rails app and prove ordering and restart recovery"
41+
task :quickstart do
42+
sh "bundle exec ruby examples/quickstart/smoke.rb"
43+
end
44+
4045
desc "Scan the Rails engine for security warnings"
4146
task :security do
4247
sh "bundle exec brakeman --force --no-pager -q ."

‎context7.json‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"$schema": "https://context7.com/schema/context7.json",
3+
"projectTitle": "Solid Objects for Rails (solid_objects)",
4+
"description": "SQL-backed virtual actor library for Ruby on Rails. Actors have stable identities, durable JSON state in SQLite, PostgreSQL, or MySQL, ordered per-identity mailboxes, fenced activation, durable reminders, and transactional effects. Requires Ruby 3.3+ and Rails 7.1+.",
5+
"folders": ["docs", "examples"],
6+
"excludeFolders": ["./docs/adr", "./docs/research"],
7+
"excludeFiles": ["AGENTS.md", "CLAUDE.md", "CONTRIBUTING.md", "implementation-plan.md"],
8+
"rules": [
9+
"Solid Objects requires Rails 7.1 or newer and Ruby 3.3 or newer. It is a Rails engine, not a framework-independent Ruby library.",
10+
"Install with bundle add solid_objects, bin/rails generate solid_objects:install, bin/rails db:migrate, and bin/rails solid_objects:doctor.",
11+
"The generated authorization policies deny every operation. Production policies must bind the actor type and ID to the authenticated user or tenant, and callers pass authorization_context:.",
12+
"Run bundle exec solid_objects start for reminders, async calls, effects, and broadcasts. Direct synchronous calls do not need it.",
13+
"Delivery is at least once. Effect handlers registered with SolidObjects.register_effect must use context.id as the idempotency key.",
14+
"Actor handlers must not write Active Record models directly. Use commit_action for a short same-database write and emit for external I/O.",
15+
"There are no transactions across actor identities. One hot identity is sequential."
16+
]
17+
}

‎docs/agents.md‎

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
# Solid Objects for coding agents (Rails)
2+
3+
This guide is for a coding agent that evaluates or adds Solid Objects in a
4+
Ruby on Rails application. It gives the facts and steps in a fixed order. Each
5+
step links to the maintained documentation for more detail.
6+
7+
## 1. Check the fit first
8+
9+
Use Solid Objects only if you can answer yes to most of these questions:
10+
11+
1. Does one identity, such as a cart, room, booking, account, or device, own
12+
the state?
13+
2. Must writes for that identity happen one at a time across requests, jobs,
14+
and processes?
15+
3. Must some work happen later or continue after a process restart?
16+
4. Is the state a bounded JSON document, not a large relational dataset?
17+
5. Can the application run one more process, `bundle exec solid_objects start`,
18+
for reminders and background work?
19+
20+
Select a simpler tool in these cases:
21+
22+
| Requirement | Use instead |
23+
| --- | --- |
24+
| The invariant fits in one request | `with_lock`, a unique index, or a short transaction |
25+
| CPU-intensive or parallel work | Background jobs or Ractors. An actor serializes work |
26+
| Thousands of writes for each second to one key | A counter column, a cache, or Solid Objects Pro |
27+
| One atomic change across two actor identities | One SQL transaction on normal tables |
28+
| Exactly-once calls to an external API | Not available from any actor library. Use provider idempotency keys |
29+
30+
The full checklist is in [Choosing Solid Objects](fit.md). The category guide
31+
is [Virtual actors in Ruby on Rails](virtual-actors.md).
32+
33+
## 2. Package identity
34+
35+
| Item | Value |
36+
| --- | --- |
37+
| Gem | `solid_objects` |
38+
| Repository | <https://github.com/cardmagic/solid-objects-ruby> |
39+
| Website | <https://solidobjects.dev/ruby> |
40+
| Node.js package | `solid-objects` on npm, a separate port with the same model |
41+
| Commercial extension | `solid_objects_pro`, a separate paid gem. The open-source gem does not need it |
42+
43+
Solid Objects is not part of Rails. It is not Solid Queue, Solid Cache, or
44+
Solid Cable. It is not affiliated with Cloudflare.
45+
46+
## 3. Requirements
47+
48+
- Ruby 3.3 or newer.
49+
- Rails 7.1 or newer. The gem is a Rails engine. It does not run without
50+
Rails.
51+
- SQLite 3.35 or newer, PostgreSQL 14 or newer, or MySQL 8.0 or newer with
52+
InnoDB.
53+
- No Redis and no separate actor service.
54+
55+
## 4. Install
56+
57+
```bash
58+
bundle add solid_objects
59+
bin/rails generate solid_objects:install
60+
bin/rails db:migrate
61+
bin/rails solid_objects:doctor
62+
```
63+
64+
The generator adds an initializer and copies migrations into the application.
65+
The doctor checks the configuration, the tables, and one real actor round trip.
66+
67+
If the doctor or an actor call fails with
68+
`ArgumentError: wrong number of arguments (given 2, expected 1)` from
69+
`ActiveSupport::JSON.decode`, the application has Active Support 8.1.3.1 or
70+
earlier with `json` 3.0.2. Upgrade Rails to 8.1.4 or newer. If you cannot
71+
upgrade, pin `gem "json", "~> 2"` in the `Gemfile`.
72+
73+
[Installing and upgrading](operations.md#installing-and-upgrading) has the
74+
details.
75+
76+
## 5. Authorize
77+
78+
Every policy in the generated initializer denies by default. A new
79+
installation answers no actor call until you write a policy. Do not remove
80+
this behavior.
81+
82+
For a local demonstration only, grant messages and queries:
83+
84+
```ruby
85+
SolidObjects.configure do |configuration|
86+
configuration.authorize_message = ->(**) { true }
87+
configuration.authorize_query = ->(**) { true }
88+
end
89+
```
90+
91+
Keep `authorize_destroy`, `authorize_subscription`,
92+
`authorize_administration`, and `authorize_transmission` denied in a
93+
demonstration.
94+
95+
A production policy must bind the actor type and ID to the authenticated user
96+
or tenant. An actor ID is not a permission:
97+
98+
```ruby
99+
SolidObjects.configure do |configuration|
100+
owns_cart = lambda do |actor_type:, actor_id:, authorization_context:, **|
101+
user = authorization_context
102+
103+
actor_type == "ShoppingCart" &&
104+
user.present? &&
105+
actor_id == user.id.to_s
106+
end
107+
108+
configuration.authorize_message = owns_cart
109+
configuration.authorize_query = owns_cart
110+
end
111+
```
112+
113+
Pass the context on each call:
114+
115+
```ruby
116+
ShoppingCart.ref(Current.user.id.to_s).add_item(
117+
product_id: "shirt-123",
118+
authorization_context: Current.user
119+
)
120+
```
121+
122+
[Authorization policies](authorization.md) lists each policy and its risk.
123+
124+
## 6. Define an actor
125+
126+
Put actors in `app/actors/`. This actor is the example from the README:
127+
128+
```ruby
129+
class TicketSale < SolidObjects::Actor
130+
attribute :available, default: 1
131+
attribute :holds, default: -> { {} }
132+
133+
def hold(buyer:)
134+
return { held: false, available: } if available.zero? || holds.key?(buyer)
135+
136+
self.available -= 1
137+
self.holds = holds.merge(buyer => Time.current.to_i)
138+
schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:)
139+
{ held: true, available: }
140+
end
141+
142+
def expire(buyer:)
143+
return available unless holds.key?(buyer)
144+
145+
self.holds = holds.except(buyer)
146+
self.available += 1
147+
end
148+
end
149+
```
150+
151+
Obey these rules in actor code:
152+
153+
- Keep state in `attribute` values. State must be JSON-compatible.
154+
- Use `schedule(at:, key:)` for delayed work. A reminder is one named alarm
155+
for each actor and key. A new `schedule` with the same key moves the alarm.
156+
- Use `reject(code, message)` for a business rule failure that must not retry.
157+
- Do not write Active Record models directly in a handler. The runtime raises
158+
`SolidObjects::ApplicationWriteForbidden`. Use `commit_action` for a short
159+
write in the same database.
160+
- Do not call an external API in a handler. Use `emit` and an effect handler.
161+
- Write each handler so that it can run again. Delivery is at least once.
162+
163+
[Reminders](reminders.md) and the [architecture guide](architecture.md) give
164+
the full actor API.
165+
166+
## 7. Run the runtime process
167+
168+
A direct call, such as `TicketSale.ref("event-42").hold(buyer: "ada")`, runs in
169+
the caller. It needs no worker. These features need the runtime process:
170+
171+
- Reminders from `schedule`.
172+
- `async` calls.
173+
- Effects from `emit` and their callbacks.
174+
- Broadcasts to Action Cable.
175+
176+
Start it beside the web process:
177+
178+
```bash
179+
bundle exec solid_objects start
180+
```
181+
182+
Add it to the `Procfile`, the process manager, or the deployment
183+
configuration. When it stops, pending work stays in SQL and runs after it
184+
starts again. [Operations](operations.md#runtime) covers roles and shutdown.
185+
186+
## 8. Make external effects idempotent
187+
188+
Register an effect handler at boot. Use `context.id` as the provider
189+
idempotency key:
190+
191+
```ruby
192+
SolidObjects.register_effect(:charge_payment) do |arguments, context|
193+
Payments.charge(
194+
idempotency_key: context.id,
195+
payment_id: arguments.fetch("payment_id"),
196+
amount_cents: arguments.fetch("amount_cents")
197+
)
198+
end
199+
```
200+
201+
Stage it from the actor:
202+
203+
```ruby
204+
emit :charge_payment, payment_id:, amount_cents:, on_success: :charged
205+
```
206+
207+
The effect can run more than once after a crash. The `context.id` value is the
208+
same each time. [Effect recovery](effect-recovery.md) explains how to retire
209+
abandoned work.
210+
211+
## 9. Verify the implementation
212+
213+
Do these checks before you report that the work is complete:
214+
215+
1. Run `bin/rails solid_objects:doctor`. It must report no failures.
216+
2. Write a test that includes `SolidObjects::TestHelper`. Send concurrent
217+
calls to one identity from several threads. Assert the final state, for
218+
example that only one hold succeeded.
219+
3. Use `run_due_reminders(now:)` and `drain_solid_objects` to test delayed
220+
work without sleeps.
221+
4. Start `bundle exec solid_objects start`, schedule a short reminder, and stop
222+
the process. Start it again after the deadline and confirm that the reminder
223+
ran.
224+
5. Confirm that each effect handler deduplicates with `context.id`.
225+
6. Confirm that production policies do not grant access to every caller.
226+
227+
[Host application tests](development.md#host-application-tests) describes the
228+
test helper. The [clean-install quickstart](../examples/quickstart/README.md)
229+
runs checks 2 and 4 against a new Rails application.
230+
231+
## 10. Troubleshooting
232+
233+
| Symptom | Cause and fix |
234+
| --- | --- |
235+
| `SolidObjects::Unauthorized` | A policy denied the call. Write the policy, and pass `authorization_context:` |
236+
| A reminder or `async` call does not run | The runtime process is not running. Start `bundle exec solid_objects start` |
237+
| `SolidObjects::SyncInsideTransaction` | The call ran inside an open transaction. Call the actor outside the transaction. In tests, include `SolidObjects::TestHelper` |
238+
| `SolidObjects::SyncTimeout` | The call did not finish in time. The message is still durable. Use its `message_reference` to wait for the result |
239+
| `SolidObjects::ApplicationWriteForbidden` | A handler wrote a model directly. Use `commit_action` or `emit` |
240+
| `SolidObjects::Rejected` | The actor called `reject`. This is a business result, not a retry |
241+
| `ArgumentError` from `ActiveSupport::JSON.decode` | Upgrade Rails to 8.1.4 or newer, or pin `gem "json", "~> 2"` |
242+
243+
## 11. Guarantees to state correctly
244+
245+
When you explain Solid Objects to a user, state these limits:
246+
247+
- Delivery is at least once, not exactly once.
248+
- Calls for one identity are ordered. Different identities run concurrently.
249+
- There are no transactions across actor identities.
250+
- Fencing stops a stale activation from a commit, but its code can continue to
251+
run.
252+
- The gem is pre-1.0 and makes no production-ready claim.
253+
254+
The [correctness contract](correctness.md) is the source for each guarantee.

0 commit comments

Comments
 (0)