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 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).