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
data/docs/findings.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# Findings: what actually needs to change?
|
|
2
|
+
|
|
3
|
+
Historical investigation from September 11. Core 0.14.6 subsequently fixed the
|
|
4
|
+
post-commit bug, and the adapter now uses published dependencies without the
|
|
5
|
+
extra transition column. See [current compatibility](compatibility.md) and
|
|
6
|
+
[current verification](verification.md); the counts and limitations below
|
|
7
|
+
describe the original prototype, not the current release candidate.
|
|
8
|
+
|
|
9
|
+
Measured on September 11, 2026. **Existing primitives support a useful
|
|
10
|
+
Statesman integration. A caller-bound execution API and synchronous joining
|
|
11
|
+
of application transactions are not prerequisites.**
|
|
12
|
+
|
|
13
|
+
This corrects the earlier observer-transport conclusion. Original Ruby object
|
|
14
|
+
identity cannot cross a durable JSON message boundary, but registered machine
|
|
15
|
+
classes and persisted parent records can reconstruct the required behavior.
|
|
16
|
+
That distinction is the basis of this prototype.
|
|
17
|
+
|
|
18
|
+
## Concurrency result
|
|
19
|
+
|
|
20
|
+
Six threads hold separate Active Record connections. A queue barrier stops
|
|
21
|
+
them after they have read the original state and entered caller-side guard
|
|
22
|
+
validation, then releases all six. These are real Statesman calls, not mocked
|
|
23
|
+
adapter calls or sequential requests.
|
|
24
|
+
|
|
25
|
+
| Scenario | Successes | Other outcomes |
|
|
26
|
+
| --- | ---: | --- |
|
|
27
|
+
| Prototype, bang single-use transition | 1 | 5 `TransitionFailedError` |
|
|
28
|
+
| Prototype, non-bang single-use transition | 1 | 5 `false` |
|
|
29
|
+
| Prototype, guarded self-transition | 1 | 5 `GuardFailedError` |
|
|
30
|
+
| Stock Active Record adapter, same single-use race | 1 | 5 `TransitionConflictError` |
|
|
31
|
+
|
|
32
|
+
The guarded self-transition consumes one persisted unit. It demonstrates why
|
|
33
|
+
checking only the source state would be insufficient.
|
|
34
|
+
|
|
35
|
+
The actor stages a commit action, and that action reconstructs the machine
|
|
36
|
+
and repeats validation before writing. Statesman also validates before
|
|
37
|
+
calling the storage adapter, so merely serializing adapter inserts would
|
|
38
|
+
leave stale caller decisions intact. See upstream
|
|
39
|
+
[`Machine#transition_to!`](https://github.com/gocardless/statesman/blob/3a7e9b7a69a6d627e8e76e84e5c59cd0827755c8/lib/statesman/machine.rb)
|
|
40
|
+
and the [Active Record adapter](https://github.com/gocardless/statesman/blob/3a7e9b7a69a6d627e8e76e84e5c59cd0827755c8/lib/statesman/adapters/active_record.rb).
|
|
41
|
+
|
|
42
|
+
Negative control: temporarily replacing the worker's `transition_to!` call
|
|
43
|
+
with a direct persistence call made **both concurrency tests fail with six
|
|
44
|
+
successes**. The authoritative validation was then restored. This test proves
|
|
45
|
+
the particular concurrency guarantee, not universal absence of SQL errors or
|
|
46
|
+
conflicts when application code bypasses the actor.
|
|
47
|
+
|
|
48
|
+
## Restrictions that matter
|
|
49
|
+
|
|
50
|
+
| Capability or restriction | Observed behavior | Practical significance |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| Registered machine reconstruction | Works in fresh actor/effect processes booted before enqueue | Existing primitives suffice; no per-request closure registry |
|
|
53
|
+
| Before/after database callbacks | Run around the row write inside the fenced transaction; failures roll everything back | Suitable for bounded same-pool database work |
|
|
54
|
+
| Inline after-commit timing | Runs after commit; transaction depth is zero | Existing Rails callback registration is enough for timing |
|
|
55
|
+
| Inline after-commit error | Originally became `LostActivation` despite successful persistence; the local core fix exposes the original `RuntimeError` | Persistence remains committed; report the callback failure in the executing process |
|
|
56
|
+
| Emitted callbacks | Deferred, retryable, can duplicate and observe later history | Use for idempotent eventual work, not identical inline semantics |
|
|
57
|
+
| Application transactions | Async enqueue commits/rolls back with application writes; sync rejects before enqueue | Existing async API solves atomic acceptance, not atomic execution |
|
|
58
|
+
| Original observer and unsaved parent | Worker uses new objects and persisted attributes | Material if callbacks depend on instance identity or unsaved data |
|
|
59
|
+
| Request-local `Current` | Visible with caller assistance, absent in a fresh worker | Pass explicit JSON data or persist context; local success can mislead |
|
|
60
|
+
| Guard evaluation | Caller and worker both evaluate guards | Guards must be pure/repeatable; worker's persisted view is authoritative |
|
|
61
|
+
| Metadata | Symbol keys become strings through JSON | Normalize at the adapter/app boundary; not a reason for arbitrary Ruby serialization |
|
|
62
|
+
| Callback errors before commit | Surface as `SolidObjects::MessageFailed` with original class in details | Exception identity changes; application retry handling needs review |
|
|
63
|
+
| Guard error details | Error retains caller parent but not original guard callback object | Class-level compatibility, not full exception-object identity |
|
|
64
|
+
| Connection pools | Registration rejects a different pool before enqueue | Same URL through separate pools is not sufficient |
|
|
65
|
+
| Nested callback transitions | Emitted callback cannot reuse the commit-only write path | Requires a new authorized actor message; nesting is not drop-in |
|
|
66
|
+
| History and state scopes | Sorted rows, +10 keys, most-recent flags and `in_state` work after restart | Preserve the useful Rails query surface |
|
|
67
|
+
|
|
68
|
+
An async command that fails later does **not** roll back application writes
|
|
69
|
+
that already committed with its enqueue. Likewise, effects cannot make remote
|
|
70
|
+
side effects part of a SQL rollback. These are different product contracts,
|
|
71
|
+
not missing event APIs.
|
|
72
|
+
|
|
73
|
+
The callback retry test deliberately writes an event and then raises: the
|
|
74
|
+
event is written twice across two effect attempts, while the transition row
|
|
75
|
+
is written once. This is a deterministic demonstration of duplicate delivery,
|
|
76
|
+
not a process-crash experiment.
|
|
77
|
+
|
|
78
|
+
## The demonstrated core bug
|
|
79
|
+
|
|
80
|
+
The original inline callback characterization raised
|
|
81
|
+
`RuntimeError, "failed after_commit"` after SQL commit. Unfixed core then did
|
|
82
|
+
the following:
|
|
83
|
+
|
|
84
|
+
1. `Executor#complete` has already saved actor state, transition rows, and
|
|
85
|
+
message completion, and deleted the claimed-message row.
|
|
86
|
+
2. The Rails after-commit callback raises while the transaction method returns.
|
|
87
|
+
3. `Executor#call` catches the error as though the actor turn had failed,
|
|
88
|
+
restores its old in-memory snapshot, and calls `fail_message`.
|
|
89
|
+
4. `matching_claim!` cannot find the already-deleted claim and raises
|
|
90
|
+
`LostActivation, "message claim changed"`.
|
|
91
|
+
|
|
92
|
+
The test observes a completed message with no message error, the committed
|
|
93
|
+
transition, no dead letter, and a successful subsequent transition. It does
|
|
94
|
+
**not** demonstrate durable-state corruption. It does demonstrate misleading
|
|
95
|
+
failure classification and lost top-level callback diagnostics.
|
|
96
|
+
|
|
97
|
+
Reproduction in the prototype:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
mise exec ruby@3.3.9 -- ruby -rbundler/setup -Itest test/prototype_test.rb \
|
|
101
|
+
--include /inline_after.commit_exception/
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The local core fix was developed against an independent failing Minitest
|
|
105
|
+
regression using Active Record callbacks without Statesman. The prototype
|
|
106
|
+
test now expects the original `RuntimeError, "failed after_commit"`, retaining
|
|
107
|
+
its persistence and subsequent-transition assertions. A separate waiting
|
|
108
|
+
caller may receive the completed result before a worker callback fails; the
|
|
109
|
+
error is exposed in the executing process without rewriting message history.
|
|
110
|
+
The [fresh-session prompt](core-follow-up-prompt.md) records the original scope.
|
|
111
|
+
|
|
112
|
+
## What is not claimed
|
|
113
|
+
|
|
114
|
+
- This does not pass the full upstream adapter contract. Upstream
|
|
115
|
+
[shared examples](https://github.com/gocardless/statesman/blob/3a7e9b7a69a6d627e8e76e84e5c59cd0827755c8/spec/statesman/adapters/shared_examples.rb)
|
|
116
|
+
expect the supplied observer itself to receive callbacks and permit repeated
|
|
117
|
+
raw `create(:x, :y)` calls. This prototype reconstructs the machine and
|
|
118
|
+
validates commands. It exposes all six requested methods but does not run
|
|
119
|
+
those RSpec examples unchanged.
|
|
120
|
+
- Initial transition creation, arbitrary custom machine constructors,
|
|
121
|
+
failure-callback cardinality/side effects, nested before/after transitions,
|
|
122
|
+
non-null/partial-index schema variants, multiple Rails versions, composite
|
|
123
|
+
keys, and sharding are not certified.
|
|
124
|
+
- Stock transition tables do not necessarily contain the prototype's extra
|
|
125
|
+
`from_state` column. Removing that schema requirement or shipping a migration
|
|
126
|
+
is adapter release work. Boot registration validation and a public factory/
|
|
127
|
+
async helper also need release polish.
|
|
128
|
+
- The code assumes one actor owns every transition writer for a parent.
|
|
129
|
+
Guarded application data changed by other writers still needs appropriate
|
|
130
|
+
application-level coordination.
|
|
131
|
+
- Fresh-process tests use real `SolidObjects::Worker` and `EffectExecutor`
|
|
132
|
+
instances. They do not exercise the supervisor CLI, host Rails autoloading,
|
|
133
|
+
abrupt worker death, stale-lease takeover, deployment upgrades, production
|
|
134
|
+
throughput, or multi-tenant revocation after enqueue.
|
|
135
|
+
- Steep checks the library with the same lenient diagnostic policy used by
|
|
136
|
+
core and shared framework signatures. Numerous Rails/Statesman boundaries
|
|
137
|
+
remain `untyped`; a green type check is not complete static verification.
|
|
138
|
+
- No gem publication, release, deployment, or upstream PR. The subsequent core
|
|
139
|
+
fix and JS parity documentation remain local and uncommitted.
|
|
140
|
+
|
|
141
|
+
## Verification
|
|
142
|
+
|
|
143
|
+
The original characterization matrix, before the core fix:
|
|
144
|
+
|
|
145
|
+
| Database | Tests | Assertions | Failures / errors / skips | Elapsed |
|
|
146
|
+
| --- | ---: | ---: | --- | ---: |
|
|
147
|
+
| SQLite | 28 | 136 | 0 / 0 / 0 | 3.21 s |
|
|
148
|
+
| PostgreSQL 18 container | 28 | 136 | 0 / 0 / 0 | 6.03 s |
|
|
149
|
+
| MySQL 8.4 container | 28 | 136 | 0 / 0 / 0 | 6.32 s |
|
|
150
|
+
|
|
151
|
+
These are single final matrix runs, not a load benchmark. The SQLite default
|
|
152
|
+
task additionally passed Standard, RuboCop (13 files), RBS generation/
|
|
153
|
+
validation, and Steep. RBS reports zero generated files when existing
|
|
154
|
+
signatures already match its output.
|
|
155
|
+
|
|
156
|
+
Runtime: Ruby 3.3.9, Active Record 8.1.3.1, Solid Objects 0.14.5 at
|
|
157
|
+
`e7b7ab7634cd2980de277fcfa9217d93c9c3aa52`, and Statesman 13.3.0 at
|
|
158
|
+
`3a7e9b7a69a6d627e8e76e84e5c59cd0827755c8`. Dependencies are pinned in
|
|
159
|
+
`Gemfile.lock`. Commands are in the [README](../README.md).
|
|
160
|
+
|
|
161
|
+
The disposable database containers were removed after verification. To create
|
|
162
|
+
equivalent local-only test services again (no production credentials):
|
|
163
|
+
|
|
164
|
+
```sh
|
|
165
|
+
docker run --detach --name statesman-prototype-pg-20260911 \
|
|
166
|
+
--publish 127.0.0.1:55439:5432 \
|
|
167
|
+
--env POSTGRES_HOST_AUTH_METHOD=trust --env POSTGRES_DB=statesman_prototype \
|
|
168
|
+
postgres:18-alpine
|
|
169
|
+
docker run --detach --name statesman-prototype-mysql-20260911 \
|
|
170
|
+
--publish 127.0.0.1:53309:3306 \
|
|
171
|
+
--env MYSQL_ALLOW_EMPTY_PASSWORD=yes --env MYSQL_DATABASE=statesman_prototype \
|
|
172
|
+
mysql:8.4
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Wait for each database to finish initialization before running its suite.
|
|
176
|
+
The connection pool is explicitly 20 because six callers wait at a barrier
|
|
177
|
+
while holding separate connections; the default pool of five cannot satisfy
|
|
178
|
+
that test's setup. These unauthenticated containers are disposable local
|
|
179
|
+
fixtures, not deployment recommendations.
|
|
180
|
+
|
|
181
|
+
The September 12 core-fix follow-up reran the prototype against the locally
|
|
182
|
+
edited core on Ruby 3.3.9 and Active Record 8.1.3.1: SQLite, PostgreSQL 18,
|
|
183
|
+
and MySQL 8.4 each passed 28 tests and 136 assertions with no failures, errors,
|
|
184
|
+
or skips. The SQLite default pipeline also passed Standard, RuboCop, RBS, and
|
|
185
|
+
Steep. The inline callback test now asserts the original `RuntimeError`;
|
|
186
|
+
its committed-history and subsequent-transition assertions are unchanged.
|
|
187
|
+
|
|
188
|
+
TDD began with the normal-transition test failing on the unimplemented
|
|
189
|
+
adapter. Subsequent red/green tests drove effect mode, connection-pool
|
|
190
|
+
validation, shared association counters, authorized callback reads, and the
|
|
191
|
+
nested-write boundary. The deliberate validation-removal experiment supplied
|
|
192
|
+
the final negative control.
|
|
193
|
+
|
|
194
|
+
The older [observer-boundary probe](../experiments/observer_boundary_probe.rb)
|
|
195
|
+
remains historical evidence of what cannot be transported, not a release gate
|
|
196
|
+
or proof that reconstruction is impossible. It is excluded from the default
|
|
197
|
+
suite and depends on core's internal test harness.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Existing-primitives experiment
|
|
2
|
+
|
|
3
|
+
The earlier observer transport probes tested a stronger compatibility goal
|
|
4
|
+
than a useful Statesman integration necessarily needs. This experiment keeps
|
|
5
|
+
Solid Objects core unchanged and tests registered, reconstructible machines.
|
|
6
|
+
|
|
7
|
+
## Decisions before implementation
|
|
8
|
+
|
|
9
|
+
1. Keep the existing Active Record transitions table. A boot-registered commit
|
|
10
|
+
action reloads the parent and machine, performs authoritative Statesman
|
|
11
|
+
validation, runs before/after callbacks, and writes the transition.
|
|
12
|
+
2. Read history directly from the table, ordered by sort key. Authorize public
|
|
13
|
+
reads; reads inside an already-authorized matching commit action use its
|
|
14
|
+
narrowly scoped execution context.
|
|
15
|
+
3. Keep `most_recent`. Clear the previous row and insert the next row in the
|
|
16
|
+
same transaction, using the column's nullability for old-row values.
|
|
17
|
+
4. Keep +10 sort keys in actor state, seeded from existing table history.
|
|
18
|
+
Rejected commit actions must roll back the actor counter and table writes.
|
|
19
|
+
5. Compare two callback modes: normal transaction-record after-commit callbacks,
|
|
20
|
+
and delayed callbacks reconstructed from an emitted effect. Measure failures
|
|
21
|
+
and duplicate delivery rather than assuming they are interchangeable.
|
|
22
|
+
6. Use a JSON tuple of parent base-class name and scalar primary key as actor
|
|
23
|
+
identity, with separate counters per transition association inside that actor.
|
|
24
|
+
7. Keep default-deny public policies. Pass an authenticated principal to public
|
|
25
|
+
calls and bind policy checks to actor identity and operation. Registrations
|
|
26
|
+
exist at boot in every process; no request-specific closures are transported.
|
|
27
|
+
|
|
28
|
+
## Behavioral experiment list
|
|
29
|
+
|
|
30
|
+
- A normal transition persists and runs callbacks around the table write.
|
|
31
|
+
- Concurrent attempts at a single-use transition have one winner; compare the
|
|
32
|
+
same race with Statesman's Active Record adapter.
|
|
33
|
+
- Guarded self-transitions recheck persisted conditions in the serialized turn.
|
|
34
|
+
- History, query scopes, and sort keys survive fresh-process execution.
|
|
35
|
+
- A boot-registered machine works in a separate worker without caller objects.
|
|
36
|
+
- Unsaved parent attributes, request-local context, and observer instance state
|
|
37
|
+
reveal the actual reconstruction restrictions.
|
|
38
|
+
- Callback database writes roll back with rejected or failed turns.
|
|
39
|
+
- Inline after-commit failure is observed after durable commit.
|
|
40
|
+
- Emitted callbacks survive restart and retry independently of transition state.
|
|
41
|
+
- Existing application transactions reject sync; async enqueue commits or
|
|
42
|
+
rolls back with the surrounding application transaction and runs afterward.
|
|
43
|
+
- Authorization and mismatched database pools fail clearly.
|
|
44
|
+
|
|
45
|
+
The final findings and revised core prompt will distinguish necessary fixes,
|
|
46
|
+
optional compatibility work, and behavior already supported. This is a
|
|
47
|
+
prototype, not a published 0.1.0 gem or a claim of complete adapter parity.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# 0.1.0 continuation
|
|
2
|
+
|
|
3
|
+
The 0.14.6 core release removes the demonstrated post-commit blocker. Keep
|
|
4
|
+
core and JS untouched and continue the registered-machine design, using the
|
|
5
|
+
published gems by default rather than a sibling checkout.
|
|
6
|
+
|
|
7
|
+
TDD scenarios, in order:
|
|
8
|
+
|
|
9
|
+
1. The standalone package declares its name, version, and released runtime dependencies.
|
|
10
|
+
2. Inline and emitted callbacks work with the stock transition schema, without `from_state`.
|
|
11
|
+
3. Public registration builds an authorized machine; missing/mismatched registration and parents fail clearly.
|
|
12
|
+
4. Initial transitions persist once and continue normal +10 history.
|
|
13
|
+
5. Callback nesting cannot reuse an already-consumed transition write.
|
|
14
|
+
6. Adapter contract behavior, including metadata and history reloads, has explicit tests and documented differences from upstream shared examples.
|
|
15
|
+
7. Installation leaves authorization denied unless the application provides policies.
|
|
16
|
+
8. The gem loads outside this workspace and the complete suite passes against the released core on all three databases.
|
|
17
|
+
|
|
18
|
+
Reconstruction still does not preserve the original observer, unsaved parent
|
|
19
|
+
attributes, request-local state, or ambient synchronous transactions. Those
|
|
20
|
+
are documented restrictions, not new core feature requests. Do not publish or
|
|
21
|
+
claim full drop-in compatibility during this continuation.
|
data/docs/releases.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Tag releases
|
|
2
|
+
|
|
3
|
+
GitHub Actions runs `.github/workflows/ci.yml` on pushes, pull requests, and
|
|
4
|
+
manual requests. SQLite runs the full Minitest, Standard, RuboCop, RBS, and
|
|
5
|
+
Steep pipeline plus a gem build. PostgreSQL 18 and MySQL 8.4 run the tests
|
|
6
|
+
against disposable service databases.
|
|
7
|
+
|
|
8
|
+
Only a `v*` tag pushed to `cardmagic/statesman-solid_objects` can run the
|
|
9
|
+
release job. That job waits for all three checks, verifies that the tag
|
|
10
|
+
equals `v` plus `Statesman::SolidObjects::VERSION`, and builds the gem before
|
|
11
|
+
requesting publishing credentials. It pushes the exact built artifact to
|
|
12
|
+
RubyGems.org and creates a GitHub Release with that gem attached.
|
|
13
|
+
|
|
14
|
+
Ordinary pushes, pull requests, fork workflows, and manual workflow requests
|
|
15
|
+
do not publish. The release job alone has `id-token: write` and
|
|
16
|
+
`contents: write`; test jobs have read-only repository access. Actions are
|
|
17
|
+
pinned to commit SHAs, and checkouts do not retain Git credentials.
|
|
18
|
+
|
|
19
|
+
## Trusted publisher
|
|
20
|
+
|
|
21
|
+
RubyGems uses the following identity:
|
|
22
|
+
|
|
23
|
+
| Setting | Value |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| Gem | `statesman-solid_objects` |
|
|
26
|
+
| Repository owner | `cardmagic` |
|
|
27
|
+
| Repository name | `statesman-solid_objects` |
|
|
28
|
+
| Workflow filename | `ci.yml` |
|
|
29
|
+
| Environment | `release` |
|
|
30
|
+
|
|
31
|
+
The GitHub `release` environment permits only tags matching `v*`, with no
|
|
32
|
+
manual approval required. RubyGems trusts that environment and workflow via
|
|
33
|
+
OIDC; no long-lived RubyGems API key is stored in GitHub.
|
|
34
|
+
|
|
35
|
+
Before the first release, configure a
|
|
36
|
+
[pending trusted publisher](https://rubygems.org/profile/oidc/pending_trusted_publishers)
|
|
37
|
+
with the values above. Leave the reusable-workflow repository fields blank.
|
|
38
|
+
The pending publisher created during setup was valid for about 12 hours.
|
|
39
|
+
Check its expiration before the first tag and recreate it if necessary.
|
|
40
|
+
The first successful push converts it into the gem's regular trusted
|
|
41
|
+
publisher. Subsequent releases use the same workflow without repeating setup.
|
|
42
|
+
See the [RubyGems trusted-publishing guide](https://guides.rubygems.org/trusted-publishing/).
|
|
43
|
+
|
|
44
|
+
## Publish a version
|
|
45
|
+
|
|
46
|
+
1. Update `lib/statesman/solid_objects/version.rb`, date the matching
|
|
47
|
+
`CHANGELOG.md` entry, and update the README's release status. Regenerate
|
|
48
|
+
`Gemfile.lock` with Bundler and update version-specific package tests.
|
|
49
|
+
2. Run `bundle exec rake` and `bundle exec rake build`.
|
|
50
|
+
3. Commit and push `main`; wait for all CI checks to pass.
|
|
51
|
+
4. Push an annotated tag matching the version. For the first release:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
git tag -a v0.1.0 -m "Version 0.1.0"
|
|
55
|
+
git push origin v0.1.0
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
5. Watch that tag's CI run. Verify the RubyGems version, GitHub Release, and
|
|
59
|
+
attached artifact before announcing publication.
|
|
60
|
+
|
|
61
|
+
Configuring this pipeline does not publish a gem. Its first OIDC exchange
|
|
62
|
+
and upload can only be validated end to end by an actual release.
|
|
63
|
+
|
|
64
|
+
If the RubyGems upload succeeds but GitHub Release creation fails, the gem is
|
|
65
|
+
already published. Check both services before retrying; RubyGems versions
|
|
66
|
+
cannot be overwritten. Finish the missing GitHub Release instead of pushing
|
|
67
|
+
the same gem again.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Verification
|
|
2
|
+
|
|
3
|
+
September 12, 2026 continuation, after Solid Objects 0.14.6 was published.
|
|
4
|
+
|
|
5
|
+
The dependency lockfile now resolves released `solid_objects 0.14.6` and
|
|
6
|
+
`statesman 13.3.0` from RubyGems. No sibling core checkout is required for
|
|
7
|
+
runtime, tests, RBS, or Steep. Ruby is 3.3.9 and Active Record is 8.1.3.1.
|
|
8
|
+
|
|
9
|
+
## Database evidence
|
|
10
|
+
|
|
11
|
+
The final 41-scenario run passed with 217 assertions and zero failures, errors, or
|
|
12
|
+
skips on each database:
|
|
13
|
+
|
|
14
|
+
| Database | Elapsed |
|
|
15
|
+
| --- | ---: |
|
|
16
|
+
| SQLite | 3.94 s |
|
|
17
|
+
| PostgreSQL 18 | 7.04 s |
|
|
18
|
+
| MySQL 8.4 | 7.66 s |
|
|
19
|
+
|
|
20
|
+
The SQLite default task also passed Standard, RuboCop (17 files), RBS
|
|
21
|
+
generation/validation, and Steep. Steep uses the installed core's signatures
|
|
22
|
+
and its lenient diagnostic policy; Rails/Statesman boundaries still include
|
|
23
|
+
untyped values.
|
|
24
|
+
|
|
25
|
+
A package test included in that matrix builds the gem, installs it into a temporary gem
|
|
26
|
+
home, and starts an unbundled Ruby process outside the workspace. It verifies
|
|
27
|
+
that the installed adapter and released core load, without the repository's
|
|
28
|
+
load path. The standalone `rake build` task also produced the local 0.1.0 artifact.
|
|
29
|
+
The README authorization example was executed against the real adapter for
|
|
30
|
+
both initial and ordinary transitions, including keyword-argument normalization.
|
|
31
|
+
|
|
32
|
+
These are integration checks, not throughput measurements or a complete Rails
|
|
33
|
+
version matrix. See [compatibility](compatibility.md) for remaining boundaries.
|
|
34
|
+
|
|
35
|
+
## TDD evidence
|
|
36
|
+
|
|
37
|
+
Observed red/green failures during this continuation:
|
|
38
|
+
|
|
39
|
+
- Missing standalone gemspec -> valid package name/version/dependencies.
|
|
40
|
+
- Missing default require entrypoint -> successful gem loading.
|
|
41
|
+
- Stock schema -> MessageFailed while the old adapter tried writing from_state;
|
|
42
|
+
removing that write and putting the source state in effect arguments fixed both modes.
|
|
43
|
+
- Public factory keyword rejected -> authorized registered-machine construction.
|
|
44
|
+
- Invalid callback mode accepted -> explicit configuration validation.
|
|
45
|
+
- Missing registration silently defaulted to orders -> required matching registration.
|
|
46
|
+
- Initial transition rejected as pending-to-pending -> distinct idempotent initialization command.
|
|
47
|
+
- Recursive before callback raised SystemStackError -> clear re-entry error and rollback.
|
|
48
|
+
- Application post-commit rejection became KeyError -> preserve unrecognized rejection codes.
|
|
49
|
+
|
|
50
|
+
The race negative control was repeated against the released dependencies:
|
|
51
|
+
temporarily replacing the worker's transition_to! with direct persistence made
|
|
52
|
+
both the single-use and guarded-self-transition tests fail with six successes.
|
|
53
|
+
Restoring validation made both pass again. The continuing suite also retains
|
|
54
|
+
the same stock Active Record comparison.
|
|
55
|
+
|
|
56
|
+
## Reproduce
|
|
57
|
+
|
|
58
|
+
With the correct Ruby environment:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
bundle install
|
|
62
|
+
bundle exec rake
|
|
63
|
+
bundle exec rake build
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
On this workstation, the Homebrew bundle launcher can select the wrong Ruby.
|
|
67
|
+
The verified interpreter-pinned equivalent is:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
mise exec ruby@3.3.9 -- ruby -S bundle install
|
|
71
|
+
mise exec ruby@3.3.9 -- ruby -rbundler/setup -S rake
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Create disposable loopback-only databases, never application databases:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
docker run --detach --name statesman-driver-pg-20260912 \
|
|
78
|
+
--publish 127.0.0.1:55439:5432 \
|
|
79
|
+
--env POSTGRES_HOST_AUTH_METHOD=trust --env POSTGRES_DB=statesman_driver \
|
|
80
|
+
postgres:18-alpine
|
|
81
|
+
docker run --detach --name statesman-driver-mysql-20260912 \
|
|
82
|
+
--publish 127.0.0.1:53309:3306 \
|
|
83
|
+
--env MYSQL_ALLOW_EMPTY_PASSWORD=yes --env MYSQL_DATABASE=statesman_driver \
|
|
84
|
+
mysql:8.4
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Wait for initialization, then run:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
SOLID_OBJECTS_DATABASE_URL=postgresql://postgres@127.0.0.1:55439/statesman_driver \
|
|
91
|
+
mise exec ruby@3.3.9 -- ruby -rbundler/setup -S rake test
|
|
92
|
+
SOLID_OBJECTS_DATABASE_URL=mysql2://root@127.0.0.1:53309/statesman_driver \
|
|
93
|
+
mise exec ruby@3.3.9 -- ruby -rbundler/setup -S rake test
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
External test databases must be empty before each run. The harness creates
|
|
97
|
+
the schema and resets its rows. It uses a pool of 20 because six racing callers
|
|
98
|
+
hold connections at a barrier. SQLite creates and removes its own temporary
|
|
99
|
+
database. Docker database fixtures can be removed and recreated after use.
|
|
100
|
+
|
|
101
|
+
At the end of this validation run, the repository was a local, unpublished
|
|
102
|
+
candidate. No core changes, JavaScript changes, remote repository creation,
|
|
103
|
+
commits, pushes, or releases were performed during that validation.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module Statesman
|
|
4
|
+
module SolidObjects
|
|
5
|
+
class Actor < ::SolidObjects::Actor
|
|
6
|
+
actor_type "statesman-transition"
|
|
7
|
+
|
|
8
|
+
attribute :sort_keys, default: -> { {} }
|
|
9
|
+
|
|
10
|
+
# @rbs (registration: String, parent_id: String, to: String, metadata: Hash[String, untyped]) -> Integer
|
|
11
|
+
def transition(registration:, parent_id:, to:, metadata:)
|
|
12
|
+
definition = SolidObjects.registrations.fetch(registration)
|
|
13
|
+
parent = parent_for(definition, parent_id)
|
|
14
|
+
from = definition.history(parent).last&.to_state || definition.machine_class.initial_state
|
|
15
|
+
stage_transition(definition, parent, to, metadata, from:, operation: "transition")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @rbs (registration: String, parent_id: String) -> Integer
|
|
19
|
+
def initialize_state(registration:, parent_id:)
|
|
20
|
+
definition = SolidObjects.registrations.fetch(registration)
|
|
21
|
+
parent = parent_for(definition, parent_id)
|
|
22
|
+
existing = definition.history(parent).first
|
|
23
|
+
return existing.sort_key if existing
|
|
24
|
+
|
|
25
|
+
stage_transition(definition, parent, definition.machine_class.initial_state, {}, from: "", operation: "initialize_state")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# @rbs (Registration, String) -> untyped
|
|
31
|
+
def parent_for(definition, parent_id)
|
|
32
|
+
parent = definition.parent_class.find(parent_id)
|
|
33
|
+
raise ArgumentError, "parent does not match actor identity" unless definition.actor_id(parent) == actor_id
|
|
34
|
+
|
|
35
|
+
parent
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @rbs (Registration, untyped, String, Hash[String, untyped], from: String, operation: String) -> Integer
|
|
39
|
+
def stage_transition(definition, parent, to, metadata, from:, operation:)
|
|
40
|
+
registration = definition.name
|
|
41
|
+
parent_id = parent.id.to_s
|
|
42
|
+
|
|
43
|
+
counter = definition.association.to_s
|
|
44
|
+
current_key = sort_keys.fetch(counter) { definition.history(parent).maximum(:sort_key) || 0 }
|
|
45
|
+
sort_key = current_key + 10
|
|
46
|
+
self.sort_keys = sort_keys.merge(counter => sort_key)
|
|
47
|
+
commit_action(:statesman_transition, registration:, parent_id:, to:, metadata:, sort_key:, operation:)
|
|
48
|
+
if definition.after_commit == :effect
|
|
49
|
+
emit(:statesman_after_commit, registration:, parent_id:, sort_key:, from:)
|
|
50
|
+
end
|
|
51
|
+
sort_key
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module Statesman
|
|
4
|
+
module SolidObjects
|
|
5
|
+
class Adapter
|
|
6
|
+
# @rbs @transition_class: untyped
|
|
7
|
+
# @rbs @parent_model: untyped
|
|
8
|
+
# @rbs @observer: untyped
|
|
9
|
+
# @rbs @definition: Registration
|
|
10
|
+
# @rbs @authorization_context: untyped
|
|
11
|
+
|
|
12
|
+
attr_reader :transition_class, :parent_model
|
|
13
|
+
|
|
14
|
+
# @rbs (untyped, untyped, untyped, ?Hash[Symbol, untyped]) -> void
|
|
15
|
+
def initialize(transition_class, parent_model, observer, options = {})
|
|
16
|
+
@transition_class = transition_class
|
|
17
|
+
@parent_model = parent_model
|
|
18
|
+
@observer = observer
|
|
19
|
+
@definition = SolidObjects.registrations.fetch(options.fetch(:solid_objects_registration).to_s)
|
|
20
|
+
raise ArgumentError, "transition class does not match registration" unless transition_class == @definition.transition_class
|
|
21
|
+
|
|
22
|
+
@definition.actor_id(parent_model)
|
|
23
|
+
@authorization_context = options[:authorization_context]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @rbs (String | Symbol | nil, String | Symbol, ?Hash[untyped, untyped]) -> untyped
|
|
27
|
+
def create(from, to, metadata = {})
|
|
28
|
+
return execution.persist(@observer, from.to_s, to.to_s, metadata) if execution
|
|
29
|
+
|
|
30
|
+
reference = Actor.ref(definition.actor_id(parent_model)).sync(authorization_context: @authorization_context)
|
|
31
|
+
identity = { registration: definition.name, parent_id: parent_model.id.to_s }
|
|
32
|
+
sort_key = if from.nil?
|
|
33
|
+
reference.initialize_state(**identity)
|
|
34
|
+
else
|
|
35
|
+
reference.transition(**identity, to: to.to_s, metadata:)
|
|
36
|
+
end
|
|
37
|
+
definition.history(parent_model).find_by!(sort_key:)
|
|
38
|
+
rescue ::SolidObjects::Rejected => rejection
|
|
39
|
+
raise unless [ "transition_failed", "guard_failed" ].include?(rejection.code)
|
|
40
|
+
|
|
41
|
+
from = rejection.details.fetch("from")
|
|
42
|
+
to = rejection.details.fetch("to")
|
|
43
|
+
raise Statesman::GuardFailedError.new(from, to, nil, parent_model) if rejection.code == "guard_failed"
|
|
44
|
+
|
|
45
|
+
raise Statesman::TransitionFailedError.new(from, to)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @rbs (?force_reload: bool) -> untyped
|
|
49
|
+
def last(force_reload: false)
|
|
50
|
+
authorize_read("last")
|
|
51
|
+
transition_class.uncached { definition.history(parent_model).last }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @rbs (?force_reload: bool) -> Array[untyped]
|
|
55
|
+
def history(force_reload: false)
|
|
56
|
+
authorize_read("history")
|
|
57
|
+
transition_class.uncached { definition.history(parent_model).to_a }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @rbs () -> nil
|
|
61
|
+
def reset
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
attr_reader :definition
|
|
68
|
+
|
|
69
|
+
# @rbs () -> Integer
|
|
70
|
+
def next_sort_key
|
|
71
|
+
(last&.sort_key || 0) + 10
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @rbs () -> Execution?
|
|
75
|
+
def execution
|
|
76
|
+
current = Execution.current
|
|
77
|
+
current if current&.matches?(definition, parent_model)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @rbs (String) -> void
|
|
81
|
+
def authorize_read(operation)
|
|
82
|
+
return if execution
|
|
83
|
+
|
|
84
|
+
allowed = ::SolidObjects.configuration.authorize_query.call(
|
|
85
|
+
actor_type: Actor.actor_type,
|
|
86
|
+
actor_id: definition.actor_id(parent_model),
|
|
87
|
+
operation:,
|
|
88
|
+
arguments: {},
|
|
89
|
+
authorization_context: @authorization_context
|
|
90
|
+
)
|
|
91
|
+
raise ::SolidObjects::Unauthorized, "transition read is not authorized" unless allowed
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|