statesman-solid_objects 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/MIT-LICENSE +21 -0
- data/README.md +197 -0
- data/docs/compatibility.md +92 -0
- data/docs/core-follow-up-prompt.md +148 -0
- data/docs/design.md +139 -0
- data/docs/findings.md +197 -0
- data/docs/prototype-plan.md +47 -0
- data/docs/release-plan.md +21 -0
- data/docs/releases.md +67 -0
- data/docs/verification.md +103 -0
- data/lib/statesman/solid_objects/actor.rb +55 -0
- data/lib/statesman/solid_objects/adapter.rb +95 -0
- data/lib/statesman/solid_objects/execution.rb +95 -0
- data/lib/statesman/solid_objects/registration.rb +53 -0
- data/lib/statesman/solid_objects/version.rb +8 -0
- data/lib/statesman/solid_objects.rb +38 -0
- data/lib/statesman-solid_objects.rb +3 -0
- data/sig/generated/statesman/solid_objects/actor.rbs +21 -0
- data/sig/generated/statesman/solid_objects/adapter.rbs +49 -0
- data/sig/generated/statesman/solid_objects/execution.rbs +48 -0
- data/sig/generated/statesman/solid_objects/registration.rbs +43 -0
- data/sig/generated/statesman/solid_objects/version.rbs +8 -0
- data/sig/generated/statesman/solid_objects.rbs +16 -0
- data/sig/generated/statesman-solid_objects.rbs +2 -0
- metadata +104 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f47ed26baff2bc6fa7e7d750f8c804398a615ac52975c878e7d1d3ab48f6a272
|
|
4
|
+
data.tar.gz: 2e558c9c39356cb31644fa824738a894f9057221f76527fefa105514dbebe7bc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ccf1bf941913c5313d43ba39f1bd3c83a456f72f6296fae4111b1762f45a750ad3bfd381cc48f1a5d48b96f396d8968c112df0b697940a58933f00fae0832810
|
|
7
|
+
data.tar.gz: a1db9a369bdd8d7d80dd09335d6b3ab9300fb815fb77b555f5e19abebaa6adcf33d4fad9cd685d07e81f36aa0fe4d875df09d4bd16c651b9eb1932c0d357637d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-09-12
|
|
4
|
+
|
|
5
|
+
- Add SQLite, PostgreSQL, and MySQL CI with gated version-tag publishing to
|
|
6
|
+
RubyGems through OIDC and automatic GitHub Releases.
|
|
7
|
+
- Introduce a Rails-only registered-machine Statesman adapter backed by Solid
|
|
8
|
+
Objects mailboxes, fenced commit actions, and Active Record transition rows.
|
|
9
|
+
- Require Solid Objects 0.14.6 for correct post-commit callback error handling.
|
|
10
|
+
- Preserve ordered transition history and state queries, with serialized
|
|
11
|
+
authoritative validation and explicit default-deny authorization.
|
|
12
|
+
- Offer inline or emitted after-commit callbacks with documented reconstruction,
|
|
13
|
+
transaction, and at-least-once delivery boundaries.
|
|
14
|
+
- Use the stock transition schema, support initial transitions, provide an
|
|
15
|
+
authorized machine factory, reject callback write re-entry, and preserve
|
|
16
|
+
unrelated application rejection errors.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Solid Objects contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# statesman-solid_objects
|
|
2
|
+
|
|
3
|
+
**Six concurrent transition attempts: one success, five domain rejections,
|
|
4
|
+
zero transition conflicts.** The same synchronized race against Statesman's
|
|
5
|
+
Active Record adapter produces one success and five `TransitionConflictError`s.
|
|
6
|
+
|
|
7
|
+
A Rails-only Statesman storage adapter using Solid Objects mailboxes and
|
|
8
|
+
transactional Active Record writes. Requires **Solid Objects 0.14.6+** and
|
|
9
|
+
Statesman 13.3. Machines are reconstructed from boot registrations and
|
|
10
|
+
persisted parents; this is not a drop-in replacement for every Statesman
|
|
11
|
+
callback pattern.
|
|
12
|
+
|
|
13
|
+
Version **0.1.0**. See [compatibility](docs/compatibility.md),
|
|
14
|
+
[the seven design decisions](docs/design.md), and
|
|
15
|
+
[verification](docs/verification.md).
|
|
16
|
+
|
|
17
|
+
**46 tests and 259 assertions pass on SQLite, PostgreSQL, and MySQL**, with
|
|
18
|
+
zero skips, including the tag-release gates. The full lint/type-check pipeline
|
|
19
|
+
and isolated gem installation also pass.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
Add the gem to your Rails application's Gemfile:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
gem "statesman-solid_objects", "~> 0.1.0"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Then run `bundle install`.
|
|
30
|
+
|
|
31
|
+
For the development version, install from GitHub:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
gem "statesman-solid_objects", github: "cardmagic/statesman-solid_objects", branch: "main"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The development bundle uses published dependencies, not sibling checkouts:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
bundle install
|
|
41
|
+
bundle exec rake
|
|
42
|
+
bundle exec rake build
|
|
43
|
+
gem install ./pkg/statesman-solid_objects-0.1.0.gem
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
For local Rails development, reference this repository:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
gem "statesman-solid_objects", path: "../statesman-solid-objects"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Install Solid Objects' tables using its Rails installation instructions. Keep
|
|
53
|
+
your Statesman transition model and table: parent foreign key, `to_state`,
|
|
54
|
+
`sort_key`, `metadata`, `most_recent`, and timestamps. No `from_state` column
|
|
55
|
+
or adapter migration is required. The tested schema uses native JSON metadata,
|
|
56
|
+
nullable `most_recent`, and full unique indexes on parent/sort key and
|
|
57
|
+
parent/most-recent. All participating models must share the same connection
|
|
58
|
+
pool as `ActiveRecord::Base` and Solid Objects.
|
|
59
|
+
|
|
60
|
+
## Configure
|
|
61
|
+
|
|
62
|
+
Register the machine in every web and worker process. Use Rails' prepare hook
|
|
63
|
+
so registrations refresh when application classes reload:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
Rails.application.config.to_prepare do
|
|
67
|
+
Statesman::SolidObjects.install
|
|
68
|
+
Statesman::SolidObjects.register(
|
|
69
|
+
:orders,
|
|
70
|
+
parent_class: Order,
|
|
71
|
+
transition_class: OrderTransition,
|
|
72
|
+
machine_class: OrderStateMachine,
|
|
73
|
+
association: :order_transitions,
|
|
74
|
+
after_commit: :inline
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
Statesman.configure do
|
|
78
|
+
storage_adapter Statesman::SolidObjects::Adapter
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`install` already selects the adapter; the explicit Statesman block shows the
|
|
84
|
+
standard configuration seam. Registration requires a normal Statesman
|
|
85
|
+
`(parent, options)` constructor. No request-specific objects are registered.
|
|
86
|
+
|
|
87
|
+
## Authorize explicitly
|
|
88
|
+
|
|
89
|
+
Installation keeps Solid Objects' default-deny policies. Supply a trusted
|
|
90
|
+
server-side principal and policies for both messages and direct history reads.
|
|
91
|
+
This example authorizes tenant members for the single `orders` registration;
|
|
92
|
+
add application-specific roles and target-state permissions as needed:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
policy = lambda do |actor_type:, actor_id:, operation:, arguments:, authorization_context:, **|
|
|
96
|
+
next false unless authorization_context
|
|
97
|
+
next false unless actor_type == "statesman-transition"
|
|
98
|
+
|
|
99
|
+
identity = JSON.parse(actor_id)
|
|
100
|
+
next false unless identity.is_a?(Array) && identity.length == 2
|
|
101
|
+
next false unless identity.first == Order.base_class.name
|
|
102
|
+
|
|
103
|
+
arguments = arguments.stringify_keys
|
|
104
|
+
case operation.to_s
|
|
105
|
+
when "transition", "initialize_state"
|
|
106
|
+
next false unless arguments["registration"] == "orders"
|
|
107
|
+
next false unless arguments["parent_id"].to_s == identity.last
|
|
108
|
+
when "last", "history"
|
|
109
|
+
else
|
|
110
|
+
next false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
Order.exists?(id: identity.last, tenant_id: authorization_context.tenant_id)
|
|
114
|
+
rescue JSON::ParserError
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
SolidObjects.configure do |configuration|
|
|
119
|
+
configuration.authorize_message = policy
|
|
120
|
+
configuration.authorize_query = policy
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Policies authorize admission, not automatic permission rechecking in a future
|
|
125
|
+
worker. Cross-record `ActiveRecordQueries` scopes still require the
|
|
126
|
+
application's normal tenant scoping.
|
|
127
|
+
|
|
128
|
+
## Transition
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
registration = Statesman::SolidObjects.registrations.fetch("orders")
|
|
132
|
+
machine = registration.machine(order, authorization_context: current_user)
|
|
133
|
+
|
|
134
|
+
machine.transition_to!(:approved, { "source" => "checkout" })
|
|
135
|
+
machine.current_state
|
|
136
|
+
machine.history
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
A failed bang transition raises `TransitionFailedError` or `GuardFailedError`;
|
|
140
|
+
the non-bang method returns `false`. Guards run again inside the serialized
|
|
141
|
+
commit action and must be pure/repeatable. Use persisted data and string-keyed
|
|
142
|
+
JSON metadata, not unsaved attributes or request-local `Current` values.
|
|
143
|
+
|
|
144
|
+
To persist the initial transition, pass `initial_transition: true` to
|
|
145
|
+
`registration.machine`. Repeated initialization requests retain one initial
|
|
146
|
+
row; subsequent transitions continue its +10 history.
|
|
147
|
+
|
|
148
|
+
The inline callback mode runs before/after observers inside the transaction
|
|
149
|
+
and after-commit observers after SQL commit. The 0.14.6 core fix preserves an
|
|
150
|
+
original after-commit exception without failing an already-completed turn.
|
|
151
|
+
|
|
152
|
+
Choose `after_commit: :effect` during registration for durable, delayed
|
|
153
|
+
callback delivery. Effects may repeat and observe later state; handlers must
|
|
154
|
+
be idempotent, and referenced transition rows must remain available.
|
|
155
|
+
|
|
156
|
+
Synchronous transitions cannot join an open application transaction. For
|
|
157
|
+
atomic acceptance with application writes, enqueue asynchronously:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
Order.transaction do
|
|
161
|
+
order.update!(label: "submitted")
|
|
162
|
+
Statesman::SolidObjects::Actor.ref(registration.actor_id(order))
|
|
163
|
+
.async(authorization_context: current_user)
|
|
164
|
+
.transition(registration: "orders", parent_id: order.id.to_s,
|
|
165
|
+
to: "approved", metadata: { "source" => "checkout" })
|
|
166
|
+
end
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Execution happens later and cannot roll back already committed caller writes.
|
|
170
|
+
Callbacks must not synchronously nest another transition; enqueue a separate
|
|
171
|
+
authorized message instead. Every transition writer for a parent must use its
|
|
172
|
+
actor.
|
|
173
|
+
|
|
174
|
+
## Development checks
|
|
175
|
+
|
|
176
|
+
`bundle exec rake` runs Minitest, Standard, RuboCop, RBS, and Steep.
|
|
177
|
+
SQLite gets a fresh temporary database. For PostgreSQL/MySQL, use a dedicated,
|
|
178
|
+
empty database through `SOLID_OBJECTS_DATABASE_URL`; never use application data.
|
|
179
|
+
See [exact commands and results](docs/verification.md).
|
|
180
|
+
|
|
181
|
+
On this workstation, pinning the Ruby interpreter avoids a Homebrew launcher
|
|
182
|
+
mismatch:
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
mise exec ruby@3.3.9 -- ruby -S bundle install
|
|
186
|
+
mise exec ruby@3.3.9 -- ruby -rbundler/setup -S rake
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`SOLID_OBJECTS_PATH` is an optional development override. It is unset during
|
|
190
|
+
release-dependency verification.
|
|
191
|
+
|
|
192
|
+
## Releases
|
|
193
|
+
|
|
194
|
+
Pushing a matching version tag such as `v0.1.0` runs all CI checks, publishes
|
|
195
|
+
the gem through RubyGems trusted publishing, and creates a GitHub Release.
|
|
196
|
+
Ordinary commits do not publish. See [release setup and steps](docs/releases.md),
|
|
197
|
+
including the pending publisher's expiration before the first release.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Compatibility boundary
|
|
2
|
+
|
|
3
|
+
This is a registered-machine adapter, not transparent transport of a live
|
|
4
|
+
Statesman observer. It uses the published Statesman 13.3 API and Solid Objects
|
|
5
|
+
0.14.6. Every process must register the same classes and association names.
|
|
6
|
+
|
|
7
|
+
## Adapter contract
|
|
8
|
+
|
|
9
|
+
The six requested methods are implemented: initialize, create, last, history,
|
|
10
|
+
reset, and private next_sort_key. The Minitest contract test exercises their
|
|
11
|
+
return values, empty history, metadata, +10 keys, reload behavior, and reads
|
|
12
|
+
after an association has already loaded. Before/after/after-commit order is
|
|
13
|
+
covered with real database callbacks and fresh-process workers.
|
|
14
|
+
|
|
15
|
+
Upstream exposes [RSpec adapter shared examples](https://github.com/gocardless/statesman/blob/3a7e9b7a69a6d627e8e76e84e5c59cd0827755c8/spec/statesman/adapters/shared_examples.rb).
|
|
16
|
+
They were inspected and their compatible behavior covered in Minitest, but
|
|
17
|
+
they do **not** run unchanged:
|
|
18
|
+
|
|
19
|
+
| Upstream expectation | This adapter |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| Supplied transition class and parent accessors | Preserved |
|
|
22
|
+
| Persisted transition returned by create | Preserved |
|
|
23
|
+
| Metadata, +10 sort keys, chronological history | Preserved |
|
|
24
|
+
| last with force_reload and observer ordering | Preserved on the reconstructed machine |
|
|
25
|
+
| Calls to the original supplied observer instance | Intentionally not preserved |
|
|
26
|
+
| Repeated raw create from x to y regardless of current graph state | Rejected when no longer a valid transition |
|
|
27
|
+
|
|
28
|
+
Direct adapter calls still require an explicit boot registration. The
|
|
29
|
+
authoritative worker check deliberately makes create a validated command,
|
|
30
|
+
not an unconditional row append. Claiming an unchanged upstream shared-suite
|
|
31
|
+
pass would be inaccurate.
|
|
32
|
+
|
|
33
|
+
## Supported and tested
|
|
34
|
+
|
|
35
|
+
- Persisted parents and named initial states.
|
|
36
|
+
- Normal Statesman constructors with explicit transition association.
|
|
37
|
+
- Initial transition creation, idempotent repeated initialization, and continued history.
|
|
38
|
+
- Native JSON transition metadata with string keys.
|
|
39
|
+
- A stock transition table without an additional from_state column.
|
|
40
|
+
- Nullable most_recent with full unique indexes on parent/flag and parent/sort key.
|
|
41
|
+
- ActiveRecordQueries state scopes and uncached last/history reads.
|
|
42
|
+
- Six concurrent single-use and guarded self-transitions, with domain rejection.
|
|
43
|
+
- Atomic same-pool database callback writes, including rollback on failure.
|
|
44
|
+
- Inline callback exceptions after successful commit, including unrelated application rejections.
|
|
45
|
+
- Delayed effect delivery, independent retries, duplicate delivery, and fresh workers.
|
|
46
|
+
- Denied absent/cross-tenant principals, unchanged default-deny installation.
|
|
47
|
+
- Published-gem loading outside the workspace.
|
|
48
|
+
|
|
49
|
+
## Restrictions applications must accept
|
|
50
|
+
|
|
51
|
+
Guards execute in the caller and again in the serialized action. They must be
|
|
52
|
+
pure/repeatable and depend on persisted data and explicit JSON inputs.
|
|
53
|
+
Unsaved attributes, request Current values, original guard callback objects,
|
|
54
|
+
and custom observer instance state do not cross the mailbox boundary.
|
|
55
|
+
Symbol-key-only guards fail after JSON normalization; use string keys.
|
|
56
|
+
|
|
57
|
+
Both pre-commit and post-commit callback nesting are rejected: one actor
|
|
58
|
+
command may persist one transition. Enqueue a new authorized command for
|
|
59
|
+
follow-up work. Before/after callbacks must contain bounded database work
|
|
60
|
+
using the shared pool, not external I/O.
|
|
61
|
+
|
|
62
|
+
Inline callbacks are not durable jobs. A separate waiter can observe committed
|
|
63
|
+
completion before its worker's callback finishes or fails. Emitted callbacks
|
|
64
|
+
are delayed and at least once, may see later state, and need idempotency.
|
|
65
|
+
Keep referenced transition rows until effects finish.
|
|
66
|
+
|
|
67
|
+
Sync calls cannot join an ambient application transaction. Async acceptance
|
|
68
|
+
can commit with caller writes, but later execution cannot undo those writes.
|
|
69
|
+
All transition writers and coordinated guard data must obey the application's
|
|
70
|
+
ownership rules; arbitrary bypass writes are not serialized by this adapter.
|
|
71
|
+
|
|
72
|
+
Registration currently validates shared pools at registration time. Do not
|
|
73
|
+
change connection routing after registration; sharded or separately pooled
|
|
74
|
+
models are not supported. Refresh registrations after Rails class reloads.
|
|
75
|
+
|
|
76
|
+
## Not certified for 0.1.0
|
|
77
|
+
|
|
78
|
+
- Failure callback cardinality/side effects: worker validation and caller
|
|
79
|
+
handling can both invoke Statesman failure callbacks; worker database writes
|
|
80
|
+
in a rejected turn roll back. Do not rely on exactly-once failure callbacks.
|
|
81
|
+
- Custom machine constructors, overridden transition methods, polymorphic
|
|
82
|
+
association layouts, composite keys, class renames, and sharding.
|
|
83
|
+
- Non-null/partial-index transition schema variants, serialized-text metadata,
|
|
84
|
+
or Statesman's cached-current-state-column extension.
|
|
85
|
+
- Populating an optional existing from_state column. The adapter does not need
|
|
86
|
+
it and currently leaves it untouched.
|
|
87
|
+
- Multiple Rails versions, production load, abrupt process death, stale-lease
|
|
88
|
+
takeover, retention/migration workflows, and supervisor CLI deployment.
|
|
89
|
+
|
|
90
|
+
A published 0.1.0 should retain these boundaries unless additional tests and
|
|
91
|
+
implementation explicitly change them. This local candidate has not been
|
|
92
|
+
published or presented as universally drop-in compatible.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Fresh Codex session prompt
|
|
2
|
+
|
|
3
|
+
Historical handoff: the concrete bug was fixed and released in Solid Objects
|
|
4
|
+
0.14.6 on September 12. Do not use this prompt as an outstanding task.
|
|
5
|
+
|
|
6
|
+
Fix Solid Objects' handling of errors raised **after a fenced actor turn has
|
|
7
|
+
already committed**. A Statesman integration prototype demonstrated this
|
|
8
|
+
specific bug using existing primitives. Do not start by adding caller-bound
|
|
9
|
+
execution, arbitrary Ruby-object transport, a new event API, or synchronous
|
|
10
|
+
joining of ambient Active Record transactions.
|
|
11
|
+
|
|
12
|
+
## Repositories and scope
|
|
13
|
+
|
|
14
|
+
Work in `~/Sites/solid_objects/solid-objects-ruby/`. Read
|
|
15
|
+
`~/Sites/solid_objects/AGENTS.md`, the repository's `AGENTS.md`, and the TDD
|
|
16
|
+
skill at `~/.claude/skills/tdd/SKILL.md` before changing code. Follow the
|
|
17
|
+
workspace's applicable skills and preserve all existing edits. Use the repo's
|
|
18
|
+
Minitest workflow for TDD. Proceed through the scoped red/green cycle without
|
|
19
|
+
per-test approval; stop if a materially broader API decision is needed.
|
|
20
|
+
|
|
21
|
+
Read the sibling prototype's `docs/findings.md`, `docs/design.md`,
|
|
22
|
+
`test/prototype_test.rb`, and `lib/statesman/solid_objects/execution.rb` in
|
|
23
|
+
`~/Sites/solid_objects/statesman-solid-objects/`. The prototype currently uses
|
|
24
|
+
core 0.14.5 at `e7b7ab7634cd2980de277fcfa9217d93c9c3aa52`; verify the current
|
|
25
|
+
checkout rather than assuming it still has the bug.
|
|
26
|
+
|
|
27
|
+
Keep Statesman a dependency of the standalone adapter only. Do not add it to
|
|
28
|
+
core, patch Statesman, publish a release, push commits/tags, deploy, or open a
|
|
29
|
+
PR. You may update the sibling's specific regression expectation after the
|
|
30
|
+
core fix, but do not expand this into finishing/publishing the adapter.
|
|
31
|
+
|
|
32
|
+
## What the prototype already proves
|
|
33
|
+
|
|
34
|
+
Using only an actor mailbox, a registered commit action, Active Record
|
|
35
|
+
transaction callbacks, and emitted effects:
|
|
36
|
+
|
|
37
|
+
- A registered machine can be rebuilt from persisted data in a fresh worker.
|
|
38
|
+
- Six racing transitions yield one success and five domain rejections, versus
|
|
39
|
+
five storage conflicts with the stock Active Record adapter.
|
|
40
|
+
- Before/after database callbacks commit or roll back with actor state.
|
|
41
|
+
- Existing Rails transaction callbacks provide inline after-commit timing.
|
|
42
|
+
- Existing effects provide deferred, retryable callback delivery.
|
|
43
|
+
- Existing async enqueue commits/rolls back with application writes, while
|
|
44
|
+
actual execution occurs later.
|
|
45
|
+
|
|
46
|
+
Do not reframe these working capabilities as missing core APIs. The prototype
|
|
47
|
+
passes 28 scenarios on SQLite, PostgreSQL, and MySQL; some are characterization
|
|
48
|
+
tests for restrictions and the bug, not evidence of full Statesman parity.
|
|
49
|
+
|
|
50
|
+
## Reproduce the bug independently of Statesman
|
|
51
|
+
|
|
52
|
+
Inspect `Executor#call`, `Executor#complete`, `fail_message`,
|
|
53
|
+
`matching_claim!`, `Lease#fenced_transaction`, and activation/synchronous
|
|
54
|
+
invocation cleanup. Also read `docs/correctness.md`, `docs/architecture.md`,
|
|
55
|
+
and the commit-action/effect documentation.
|
|
56
|
+
|
|
57
|
+
A commit action registers a Rails after-commit callback. SQL commits actor
|
|
58
|
+
state, application rows, and message completion, and deletes the claim. The
|
|
59
|
+
callback then raises. Executor treats it as a pre-commit failure and tries to
|
|
60
|
+
fail a message whose claim is already gone. The original exception is masked
|
|
61
|
+
as `SolidObjects::LostActivation: message claim changed`.
|
|
62
|
+
|
|
63
|
+
The sibling test named "an inline after-commit exception is masked as lost
|
|
64
|
+
activation after persistence" demonstrates this. It currently asserts the
|
|
65
|
+
bug to keep the characterization suite green. The message stays completed
|
|
66
|
+
and a subsequent transition succeeds; do not claim observed durable data
|
|
67
|
+
corruption.
|
|
68
|
+
|
|
69
|
+
Write a focused core regression FIRST, using a normal test Active Record
|
|
70
|
+
model callback or transaction callback inside a registered commit action.
|
|
71
|
+
No Statesman dependency or mocks bypassing real transaction/cleanup behavior.
|
|
72
|
+
Watch it fail for this exact reason before implementing a fix.
|
|
73
|
+
|
|
74
|
+
## Required semantics
|
|
75
|
+
|
|
76
|
+
Design the smallest fix and make the boundary explicit:
|
|
77
|
+
|
|
78
|
+
1. Distinguish a transaction that rolled back from one that committed before
|
|
79
|
+
a callback or post-commit reporting step raised. Do not infer commitment
|
|
80
|
+
merely from reaching the end of the transaction block before SQL commit.
|
|
81
|
+
2. A committed turn must not enter the pre-commit rejection/failure path,
|
|
82
|
+
restore its old actor snapshot, requeue its business action, create a false
|
|
83
|
+
dead letter, or report missing claim ownership as the callback error.
|
|
84
|
+
3. Preserve a truthful completed message/result and committed state. A
|
|
85
|
+
callback error cannot retroactively roll back those writes.
|
|
86
|
+
4. Preserve useful original exception class, message, and cause/backtrace.
|
|
87
|
+
A process executing the failing callback must expose that failure through
|
|
88
|
+
an explicit error/reporting path. Decide and document whether its direct
|
|
89
|
+
caller receives the original error or a dedicated post-commit error.
|
|
90
|
+
5. A separate waiter may already have observed completion before the worker
|
|
91
|
+
callback fails. Do not promise identical exception delivery to every
|
|
92
|
+
caller or add distributed acknowledgement machinery to pretend otherwise.
|
|
93
|
+
Provide or reuse an observable worker diagnostic without relabeling the
|
|
94
|
+
committed message as a failed actor turn.
|
|
95
|
+
6. Leave the activation either consistent with committed state or safely
|
|
96
|
+
discarded/reloaded. Preserve lease/claim cleanup and later mailbox progress.
|
|
97
|
+
7. Pre-commit callback failures/rejections must still roll back and follow
|
|
98
|
+
the existing retry/rejection policy. Effects retain their independent
|
|
99
|
+
at-least-once delivery behavior; do not introduce exactly-once claims.
|
|
100
|
+
|
|
101
|
+
Determine whether post-commit `Rejected` or `LostActivation` exceptions need
|
|
102
|
+
the same phase-aware handling rather than letting exception class override
|
|
103
|
+
the already-committed fact. Avoid rescuing and silently swallowing all errors.
|
|
104
|
+
|
|
105
|
+
## Validation
|
|
106
|
+
|
|
107
|
+
Test successful callbacks, failure before commit, failure after commit,
|
|
108
|
+
committed application/actor/message state, no replay or false dead letter,
|
|
109
|
+
accurate diagnostics, and the next message on the same actor. Cover caller
|
|
110
|
+
assistance and a real separate worker with a waiting caller. Use barriers/
|
|
111
|
+
pipes rather than timing sleeps, and verify the runtime's actual load path.
|
|
112
|
+
|
|
113
|
+
After the fix, temporarily revert only the fix and show that the regression
|
|
114
|
+
fails again for the original reason. Restore it and run the full core
|
|
115
|
+
`bundle exec rake` and dedicated-database PostgreSQL/MySQL tests using
|
|
116
|
+
`SOLID_OBJECTS_DATABASE_URL`. Never point the harness at application data.
|
|
117
|
+
Use Ruby 3.3.9 explicitly if the Homebrew launcher selects Ruby 4.
|
|
118
|
+
|
|
119
|
+
Run the sibling prototype again, changing only the known-bug assertion to the
|
|
120
|
+
documented new contract. Its ordinary transitions, concurrency negative
|
|
121
|
+
control, effect retries, and async transaction examples must remain valid.
|
|
122
|
+
|
|
123
|
+
Assess parity scope before editing JS: a Rails transaction callback surface
|
|
124
|
+
is Rails-only; a shared executor correctness change may need an equivalent
|
|
125
|
+
JS fix and the workspace parity workflow. Follow that distinction rather
|
|
126
|
+
than adding a Statesman port or reflexively editing both runtimes.
|
|
127
|
+
|
|
128
|
+
Update relevant correctness/error-boundary documentation and CHANGELOG.
|
|
129
|
+
Update roadmap/parity only as required by applicable repo/workspace rules.
|
|
130
|
+
Owned Ruby files must start with `# rbs_inline: enabled` and use `# @rbs`
|
|
131
|
+
annotations; no explanatory inline source comments. Put reasoning in docs
|
|
132
|
+
and the handoff.
|
|
133
|
+
|
|
134
|
+
## Explicitly deferred decisions
|
|
135
|
+
|
|
136
|
+
Preserving the caller's original observer, unsaved attributes, and ambient
|
|
137
|
+
`Current`, or making transition execution join an outer transaction, are
|
|
138
|
+
separate compatibility/product requirements. The existing-primitives
|
|
139
|
+
prototype does not establish a need for either. Do not implement them here.
|
|
140
|
+
|
|
141
|
+
String/symbol metadata normalization, extra transition schema fields, initial
|
|
142
|
+
transitions, custom machine factories, nested transitions, and failure-callback
|
|
143
|
+
compatibility belong to the standalone adapter's release work unless an
|
|
144
|
+
independent core regression demonstrates otherwise.
|
|
145
|
+
|
|
146
|
+
Finish with the cause, chosen post-commit semantics, changed files, actual
|
|
147
|
+
red/green and database results, parity assessment, and remaining restrictions.
|
|
148
|
+
Do not call the standalone gem fully compatible or released.
|
data/docs/design.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Design: existing-primitives Statesman integration
|
|
2
|
+
|
|
3
|
+
Status: unpublished 0.1.0 registered-machine adapter, not full drop-in compatibility. Decisions were
|
|
4
|
+
outlined in [the experiment plan](prototype-plan.md) before implementation;
|
|
5
|
+
this document records the resulting design and evidence. It supersedes the
|
|
6
|
+
earlier conclusion that original-observer transport blocks any integration.
|
|
7
|
+
|
|
8
|
+
## 1. Transitions stay in Active Record
|
|
9
|
+
|
|
10
|
+
Keep the transitions table and Rails model/query surfaces. The actor handler
|
|
11
|
+
only reads application records and stages intents. A boot-registered
|
|
12
|
+
`commit_action` reloads the parent, constructs its registered state machine,
|
|
13
|
+
revalidates the transition and guards, invokes before/after observers, and
|
|
14
|
+
writes the transition. Those writes and actor state commit together.
|
|
15
|
+
|
|
16
|
+
The authoritative check belongs in this serialized action, not only in the
|
|
17
|
+
caller's `Machine#transition_to!`. When it was removed experimentally, both
|
|
18
|
+
single-use-transition and guarded-self-transition races admitted all six
|
|
19
|
+
callers. With it present, each admitted one caller and rejected five.
|
|
20
|
+
|
|
21
|
+
The caller's observer is not serialized. A worker-local execution context
|
|
22
|
+
routes the reconstructed machine's adapter back into the current commit
|
|
23
|
+
action without recursively sending another actor message. It is scoped with
|
|
24
|
+
`ensure`, restricted to the matching registration and parent, and not a
|
|
25
|
+
request-local registry pretending to be durable transport.
|
|
26
|
+
|
|
27
|
+
The adapter uses the stock transition schema. The actor captures the source
|
|
28
|
+
state in the emitted effect arguments, so delayed callbacks need no extra
|
|
29
|
+
`from_state` column. This relies on the same sole-writer premise as the counter.
|
|
30
|
+
|
|
31
|
+
## 2. Reads go directly to the transitions table
|
|
32
|
+
|
|
33
|
+
`last` and `history` use uncached Active Record queries ordered by `sort_key`.
|
|
34
|
+
They do not enqueue actor queries. The actor is the sole transition writer,
|
|
35
|
+
so committed table rows are the useful public read model.
|
|
36
|
+
|
|
37
|
+
Both read methods call the application's `authorize_query` policy, except
|
|
38
|
+
inside the matching, already-admitted worker execution. Both callback modes
|
|
39
|
+
can read that machine's state. Reads are current at the database query, not
|
|
40
|
+
linearizable mailbox barriers relative to work still queued. `force_reload`
|
|
41
|
+
has no extra effect because these methods do not cache. `reset` is a no-op.
|
|
42
|
+
Cross-record `ActiveRecordQueries` scopes remain ordinary application queries
|
|
43
|
+
and need the application's usual tenant scoping.
|
|
44
|
+
|
|
45
|
+
## 3. Keep most_recent, remove contention workarounds
|
|
46
|
+
|
|
47
|
+
Keep `most_recent` and normal uniqueness protection for query compatibility.
|
|
48
|
+
Clear the old true row before inserting the new true row, inside the same
|
|
49
|
+
transaction. Use `nil` for nullable columns and `false` for non-nullable ones.
|
|
50
|
+
The tested schema uses a nullable column and a full unique index on parent
|
|
51
|
+
and `most_recent`, portable across the three databases.
|
|
52
|
+
|
|
53
|
+
There is no MySQL temporary write of a new row's flag, partial-index check,
|
|
54
|
+
`requires_new` transaction, or Statesman conflict retry loop in this adapter.
|
|
55
|
+
The mailbox removes competing transition writers, not the need to maintain
|
|
56
|
+
the query marker. Core still uses its own SQL locks/fencing and retry policy.
|
|
57
|
+
Other writers bypassing the actor invalidate the premise.
|
|
58
|
+
|
|
59
|
+
## 4. Persist +10 sort keys in actor state
|
|
60
|
+
|
|
61
|
+
The actor stores counters keyed by transition association. On first use, seed
|
|
62
|
+
from that association's highest existing `sort_key`; then increment by ten.
|
|
63
|
+
Different registrations sharing the same history share its counter. Failed
|
|
64
|
+
or rejected commit actions roll back the counter as well as application writes.
|
|
65
|
+
|
|
66
|
+
Initial-transition creation is a separate mailbox operation. It returns an
|
|
67
|
+
existing first transition when history already exists; otherwise it persists
|
|
68
|
+
the named initial state without demanding a self-transition in the graph.
|
|
69
|
+
|
|
70
|
+
Fresh OS-process workers produced keys 10 and 20 and ordered history after
|
|
71
|
+
restart. No actor eviction/restart resets the durable counter. External table
|
|
72
|
+
imports, deleting actor state, and mixing actor/non-actor writers require a
|
|
73
|
+
separate migration or recovery procedure; they are not supported workflows.
|
|
74
|
+
|
|
75
|
+
## 5. Offer two explicit after_commit boundaries
|
|
76
|
+
|
|
77
|
+
Inline mode uses Statesman's `ActiveRecordAfterCommitWrap` through
|
|
78
|
+
`add_transaction_record` on the connection executing the fenced turn. The
|
|
79
|
+
observer runs after SQL commit: measured transaction depths were 1 for
|
|
80
|
+
before/after and 0 for after_commit. It runs on the reconstructed machine,
|
|
81
|
+
not the original caller object. It is not a durable callback queue, and a
|
|
82
|
+
remote caller's receipt of completion need not wait for that worker callback.
|
|
83
|
+
|
|
84
|
+
Effect mode stages `emit(:statesman_after_commit, ...)` in the actor handler.
|
|
85
|
+
The outbox entry commits with the transition. An effect worker reconstructs
|
|
86
|
+
the machine and exact transition row later. Tests prove fresh-process
|
|
87
|
+
delivery, independent retries, repeated side effects, and observation of
|
|
88
|
+
later transition history. Handlers must be idempotent. Transition rows must
|
|
89
|
+
remain available until effects finish.
|
|
90
|
+
|
|
91
|
+
Neither mode permits callbacks to reuse a transition write, including before/
|
|
92
|
+
after callbacks while the transaction is open. A single-use write guard rejects
|
|
93
|
+
re-entry clearly; failures inside the transaction roll back the original write.
|
|
94
|
+
Follow-up transitions need a separate authorized actor message.
|
|
95
|
+
|
|
96
|
+
Core 0.14.6 fixes the demonstrated post-commit exception masking, and is now
|
|
97
|
+
the minimum dependency. Tests verify the original RuntimeError and unrelated
|
|
98
|
+
application Rejected exceptions while the transition remains committed. The
|
|
99
|
+
adapter translates only its recognized validation rejection codes.
|
|
100
|
+
|
|
101
|
+
## 6. One actor per persisted parent
|
|
102
|
+
|
|
103
|
+
The actor type is `statesman-transition`; its ID is a JSON tuple of the
|
|
104
|
+
parent's base-class name and scalar primary key converted to a string.
|
|
105
|
+
`Order(42)` and `Invoice(42)` have different identities. STI subclasses share
|
|
106
|
+
their base table's record identity. Association counters allow multiple
|
|
107
|
+
machines on that parent without multiple independent writers.
|
|
108
|
+
|
|
109
|
+
Boot registrations map trusted names to model/machine classes. Messages carry
|
|
110
|
+
registration name, parent ID, target state, and JSON metadata, not class names
|
|
111
|
+
to constantize. The actor verifies that the resolved parent matches its own
|
|
112
|
+
ID. Missing registration, mismatched transition classes, and unsaved/wrong-class
|
|
113
|
+
parents fail clearly. Composite keys, sharding, and class renames are outside
|
|
114
|
+
the tested identity contract. The same-pool registration check deliberately
|
|
115
|
+
rejects separate pools even if their URLs identify the same database.
|
|
116
|
+
|
|
117
|
+
## 7. Explicit default-deny authorization
|
|
118
|
+
|
|
119
|
+
Installation does not replace core's denying policies. The application must
|
|
120
|
+
provide `authorize_message` and `authorize_query` and pass a trusted principal
|
|
121
|
+
to public calls. The example policy binds permissions to parent class, parent
|
|
122
|
+
ID, tenant, and allowed operations. More complex applications must also check
|
|
123
|
+
the registration/transition permissions in message arguments.
|
|
124
|
+
|
|
125
|
+
The internal execution context only allows matching reads and fenced commit
|
|
126
|
+
writes for previously admitted work. It does not reconstruct the caller's
|
|
127
|
+
principal or silently forward `Current`. Worker-time revocation checks, when
|
|
128
|
+
required, belong in explicit persisted business policy. Tests deny missing
|
|
129
|
+
principals and cross-tenant reads/writes before actor creation.
|
|
130
|
+
|
|
131
|
+
## Compatibility boundary
|
|
132
|
+
|
|
133
|
+
This design trades original-object identity and ambient transaction semantics
|
|
134
|
+
for durable, independently executable commands. That is useful integration,
|
|
135
|
+
but not the entire upstream adapter contract. Repeated guards, JSON metadata,
|
|
136
|
+
failure callback behavior, and callback identity restrictions are listed in
|
|
137
|
+
[compatibility](compatibility.md). Historical prototype findings remain in
|
|
138
|
+
[the dated investigation](findings.md); current results are in
|
|
139
|
+
[verification](verification.md).
|