statecraft 0.6.0 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4689cea0dee40236473975f52ea5ccd2ecd303783edebd7626ab1518a1f8c8bd
4
- data.tar.gz: b3713ea5d55ea4881b55b10ceccb38847f2f85aa971d29326fd26e97412d0620
3
+ metadata.gz: 5e98ec25e4cfbb6cec172cd1f54ac9362937c967eff450ccda67d414e2dce6d3
4
+ data.tar.gz: e8f112727ef007ad0d6d3e91e67c122298bbf1d403f09aa9e2aeda56e43545da
5
5
  SHA512:
6
- metadata.gz: 9690fff2fb62feaaea147a53c63113a77dc93103432ad3f5aa63b5593173d513d2abf83d5d53decf0d6eed7347d94509197f5bb0d03077cdbd5a98f1f20cefd4
7
- data.tar.gz: cbd7e8d84af570a4c2cb1e51582262860b2d6d0cd7d87ed0304528413ca1d34720dbe6c9b4186fab5a68639c6814243ed2fed32f9774d4985a5f4f5408b45ede
6
+ metadata.gz: ed3978c9e0098cfc1ed4a19b933ce2a134e8841385e0e9542c267e43b6095f81c2b97b230a40d04605d0618717b3af289716157c3ab029f85b30886c54a11cb5
7
+ data.tar.gz: 626c429ccc91fc9f40401e58ab6142b81544684d57890fb0f51cec79d901e6280d06e2a14afb0400bb6d592faf33e5dbab5ef7c09045e4435b8557dd2162fb73
data/README.md CHANGED
@@ -210,6 +210,70 @@ through untouched: those errors mean "restart the whole transaction", a
210
210
  different protocol than the conflict's "continue from clean state", and the
211
211
  retry policy belongs to whoever chose the isolation level.
212
212
 
213
+ ## Stale transitions: versioning against ABA
214
+
215
+ The CAS above compares the state's *value* — so a state that went away and
216
+ came back (`pending → paid → … → pending`) passes for the state an open
217
+ page rendered minutes ago. That is the ABA problem, and `versioning: true`
218
+ closes it:
219
+
220
+ <!-- illustrative -->
221
+ ```ruby
222
+ class Order < ApplicationRecord
223
+ state_machine OrderFlow, versioning: true # the state_version column
224
+ end
225
+ ```
226
+
227
+ With the option every transition compares-and-swaps on the *pair* of state
228
+ and version and increments the version in the same UPDATE — a returned
229
+ state no longer matches, even without any token. `true` names the column
230
+ `<column>_version`; a symbol overrides the name. The column is a plain
231
+ `bigint NOT NULL DEFAULT 0`: `rails g statecraft:machine Order --versioning`
232
+ generates it, and for an existing table one step is enough — on
233
+ PostgreSQL 11+ an `add_column` with a constant default is metadata-only,
234
+ no table rewrite:
235
+
236
+ <!-- illustrative -->
237
+ ```ruby
238
+ add_column :orders, :state_version, :bigint, null: false, default: 0
239
+ ```
240
+
241
+ The user-facing half is the `seen:` token: render the version into the
242
+ form, send it back, and the pipeline refuses the action when the row has
243
+ moved on — `Statecraft::StaleTransition`, a `TransitionConflict` subclass
244
+ carrying `expected_version` and `seen`, published as reason `:stale`:
245
+
246
+ <!-- illustrative -->
247
+ ```erb
248
+ <input type="hidden" name="seen" value="<%= order.state_version %>">
249
+ ```
250
+
251
+ <!-- illustrative -->
252
+ ```ruby
253
+ def cancel
254
+ order = Order.find(params[:id])
255
+ order.cancel!(metadata: cancel_metadata, seen: params[:seen])
256
+ redirect_to order_path(order), notice: "Cancelled."
257
+ rescue Statecraft::StaleTransition
258
+ head :conflict # the API mapping: 409
259
+ # a form flow redirects instead:
260
+ # redirect_to order_path(order),
261
+ # alert: "This order changed while the page was open."
262
+ end
263
+ ```
264
+
265
+ `seen:` rides all four surface forms and the helper verbs. A string token
266
+ straight from params is fine — the pipeline normalizes it with `Integer()`,
267
+ and garbage raises `ArgumentError` loudly. The honest limits: staleness
268
+ exists only where a token was given — without `seen:` a version mismatch
269
+ is the ordinary `TransitionConflict`; the early deterministic check runs
270
+ only under `lock: true`, where the reload holds the row's real version —
271
+ without the lock the CAS itself is the check, since a fresh token can
272
+ outrun a stale in-memory record; and `seen:` on a mounting without
273
+ `versioning:` raises a `CompilationError` naming the fix instead of
274
+ silently skipping the protection. Reads stay join-free: the version lives
275
+ on the parent row, right next to the state.
276
+
213
277
  ## Introspection
214
278
 
215
279
  <!-- illustrative -->
@@ -644,7 +708,7 @@ actions over services, guard refusals local to their form:
644
708
  @order = Order.find(params[:id])
645
709
  authorize! event_name, @order
646
710
  @metadata = submitted_metadata
647
- @order.fire!(event_name, metadata: @metadata)
711
+ @order.fire!(event_name, metadata: @metadata, seen: params[:seen].presence)
648
712
  redirect_to admin_order_path(@order),
649
713
  notice: "#{event_name} fired: the order is now #{@order[:state]}."
650
714
  rescue Statecraft::GuardFailed => error
@@ -668,6 +732,7 @@ possibility-times-permission intersection:
668
732
  <!-- readme: preview-pattern -->
669
733
  ```erb
670
734
  <%= form_with url: preview_admin_order_path(@order), method: :post, local: true do %>
735
+ <input type="hidden" name="seen" value="<%= @order[:state_version] %>">
671
736
  <fieldset>
672
737
  <legend>Metadata for the next action</legend>
673
738
  <label>
@@ -705,13 +770,12 @@ ActiveSupport::Notifications.subscribe("transition.statecraft") do |_name, _star
705
770
  )
706
771
  end
707
772
 
773
+ # The failure payload names the record, the machine and the reason — it
774
+ # carries no from/to/event keys: the transition never happened.
708
775
  ActiveSupport::Notifications.subscribe("transition_failed.statecraft") do |_name, _started, _finished, _id, payload|
709
776
  OperationEntry.create!(
710
777
  record_class: payload[:record_class],
711
778
  record_id: payload[:record_id].to_s,
712
- from_state: payload[:from],
713
- to_state: payload[:to],
714
- event_name: payload[:event],
715
779
  outcome: "refused",
716
780
  reason: payload[:reason].to_s
717
781
  )
@@ -13,8 +13,13 @@ Description:
13
13
  migration lands in the migrations_paths configured for that database
14
14
  (db/migrate when none is configured).
15
15
 
16
+ With --versioning the migration also adds a state_version column and
17
+ the mounting carries versioning: true — a tagged CAS that refuses
18
+ stale seen: tokens with Statecraft::StaleTransition.
19
+
16
20
  Example:
17
21
  bin/rails generate statecraft:machine Order
22
+ bin/rails generate statecraft:machine Order --versioning
18
23
  bin/rails generate statecraft:machine Shop::Order
19
24
 
20
25
  This will create:
@@ -18,6 +18,10 @@ module Statecraft
18
18
 
19
19
  source_root File.expand_path("templates", __dir__)
20
20
 
21
+ class_option :versioning, type: :boolean, default: false,
22
+ desc: "Add a state_version column and mount with versioning: " \
23
+ "true — a tagged CAS that refuses stale seen: tokens"
24
+
21
25
  def detect_model_presence
22
26
  @existing_model = File.exist?(File.join(destination_root, model_file))
23
27
  end
@@ -71,7 +75,17 @@ module Statecraft
71
75
  end
72
76
 
73
77
  def mounting_line
74
- " state_machine #{class_name}Flow, changed_at: true, helpers: true, scopes: true\n"
78
+ " state_machine #{class_name}Flow, #{mounting_options}\n"
79
+ end
80
+
81
+ def mounting_options
82
+ options_list = ["changed_at: true"]
83
+ options_list << "versioning: true" if options[:versioning]
84
+ (options_list + ["helpers: true", "scopes: true"]).join(", ")
85
+ end
86
+
87
+ def versioning?
88
+ options[:versioning]
75
89
  end
76
90
 
77
91
  # inject_into_class matches the literal `class <name>` line, so the name
@@ -5,6 +5,9 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
5
5
  add_column :<%= table_name %>, :state, :string, null: false, default: "pending"
6
6
  add_index :<%= table_name %>, :state
7
7
  add_column :<%= table_name %>, :state_changed_at, :datetime
8
+ <% if versioning? -%>
9
+ add_column :<%= table_name %>, :state_version, :bigint, null: false, default: 0
10
+ <% end -%>
8
11
 
9
12
  # The existing table may carry legacy values, so no CHECK constraint is
10
13
  # generated here. To add one later without blocking writes:
@@ -5,6 +5,9 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
5
5
  create_table :<%= table_name %> do |t|
6
6
  t.string :state, null: false, default: "pending", index: true
7
7
  t.datetime :state_changed_at
8
+ <% if versioning? -%>
9
+ t.bigint :state_version, null: false, default: 0
10
+ <% end -%>
8
11
  t.timestamps null: false
9
12
  end
10
13
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class <%= class_name %> < ApplicationRecord
4
- state_machine <%= class_name %>Flow, changed_at: true, helpers: true, scopes: true
4
+ state_machine <%= class_name %>Flow, <%= mounting_options %>
5
5
  end
@@ -38,11 +38,26 @@ module Statecraft
38
38
  class TransitionConflict < Error
39
39
  attr_reader :record, :expected_from
40
40
 
41
- def initialize(record:, expected_from:)
41
+ def initialize(record:, expected_from:, message: nil)
42
42
  @record = record
43
43
  @expected_from = expected_from
44
- super("concurrent update detected for #{record.class.name}##{record.id}: " \
45
- "expected state #{expected_from.inspect}, another writer got there first")
44
+ super(message || "concurrent update detected for #{record.class.name}##{record.id}: " \
45
+ "expected state #{expected_from.inspect}, another writer got there first")
46
+ end
47
+ end
48
+
49
+ # The refusal of a seen: token: the snapshot the caller acted on is no
50
+ # longer the row's version. A subclass of TransitionConflict, so existing
51
+ # rescues keep catching it; controllers map it to 409 specifically.
52
+ class StaleTransition < TransitionConflict
53
+ attr_reader :expected_version, :seen
54
+
55
+ def initialize(record:, expected_from:, expected_version:, seen:)
56
+ @expected_version = expected_version
57
+ @seen = seen
58
+ super(record: record, expected_from: expected_from,
59
+ message: "stale transition for #{record.class.name}##{record.id}: " \
60
+ "the caller saw version #{seen.inspect}, but the row has moved on")
46
61
  end
47
62
  end
48
63
 
@@ -10,16 +10,17 @@ module Statecraft
10
10
  # in_state?). Everything internal goes through base_class.
11
11
  module Mounting
12
12
  Configuration = Struct.new(
13
- :machine_class, :log_class, :column, :changed_at_column, :touch, :helpers, :scopes,
14
- :log_foreign_key,
13
+ :machine_class, :log_class, :column, :changed_at_column, :version_column, :touch,
14
+ :helpers, :scopes, :log_foreign_key,
15
15
  keyword_init: true
16
16
  )
17
17
 
18
- def state_machine(machine_class, log: nil, column: :state, changed_at: false, touch: true,
19
- helpers: false, scopes: false)
18
+ def state_machine(machine_class, log: nil, column: :state, changed_at: false,
19
+ versioning: false, touch: true, helpers: false, scopes: false)
20
20
  Builder.new(
21
21
  model: self, machine_class: machine_class, log: log, column: column,
22
- changed_at: changed_at, touch: touch, helpers: helpers, scopes: scopes
22
+ changed_at: changed_at, versioning: versioning, touch: touch,
23
+ helpers: helpers, scopes: scopes
23
24
  ).mount
24
25
  end
25
26
 
@@ -27,16 +28,20 @@ module Statecraft
27
28
  # class hierarchies only — never on the schema and never on a live
28
29
  # connection, so mounting is safe at load time.
29
30
  class Builder
30
- def initialize(model:, machine_class:, log:, column:, changed_at:, touch:, helpers:, scopes:)
31
+ # rubocop:disable Metrics/ParameterLists -- mirrors state_machine's kwargs one-to-one
32
+ def initialize(model:, machine_class:, log:, column:, changed_at:, versioning:, touch:,
33
+ helpers:, scopes:)
31
34
  @model = model
32
35
  @machine_class = machine_class
33
36
  @log_option = log
34
37
  @column = column
35
38
  @changed_at = changed_at
39
+ @versioning = versioning
36
40
  @touch = touch
37
41
  @helpers = helpers
38
42
  @scopes = scopes
39
43
  end
44
+ # rubocop:enable Metrics/ParameterLists
40
45
 
41
46
  def mount
42
47
  assert_not_mounted
@@ -132,6 +137,7 @@ module Statecraft
132
137
  log_class: log_class,
133
138
  column: @column.to_sym,
134
139
  changed_at_column: resolve_changed_at_column,
140
+ version_column: resolve_version_column,
135
141
  touch: @touch,
136
142
  helpers: @helpers,
137
143
  scopes: @scopes,
@@ -147,6 +153,14 @@ module Statecraft
147
153
  end
148
154
  end
149
155
 
156
+ def resolve_version_column
157
+ case @versioning
158
+ when false then nil
159
+ when true then :"#{@column}_version"
160
+ else @versioning.to_sym
161
+ end
162
+ end
163
+
150
164
  def store_configuration(configuration)
151
165
  model.define_singleton_method(:statecraft_mounting) { configuration }
152
166
  end
@@ -163,8 +177,12 @@ module Statecraft
163
177
  def define_verbs(graph)
164
178
  verbs = Module.new do
165
179
  graph.events.each_key do |event_name|
166
- define_method("#{event_name}!") { |metadata: {}| fire!(event_name, metadata: metadata) }
167
- define_method(event_name) { |metadata: {}| fire(event_name, metadata: metadata) }
180
+ define_method("#{event_name}!") do |metadata: {}, seen: nil|
181
+ fire!(event_name, metadata: metadata, seen: seen)
182
+ end
183
+ define_method(event_name) do |metadata: {}, seen: nil|
184
+ fire(event_name, metadata: metadata, seen: seen)
185
+ end
168
186
  define_method("may_#{event_name}?") { |metadata: {}| can_fire?(event_name, metadata: metadata) }
169
187
  end
170
188
  end
@@ -5,24 +5,24 @@ module Statecraft
5
5
  # The record-facing API mixed into the model at mounting time. Bang
6
6
  # variants return the created log record; non-bang variants return it too,
7
7
  # or false on GuardFailed / InvalidTransition. Programmer errors and
8
- # TransitionConflict always raise.
8
+ # TransitionConflict — StaleTransition included — always raise.
9
9
  module Surface
10
- def transition_to!(to_state, metadata: {}, bypass_events: false)
11
- Pipeline.new(self).direct(to_state, metadata: metadata, bypass_events: bypass_events)
10
+ def transition_to!(to_state, metadata: {}, bypass_events: false, seen: nil)
11
+ Pipeline.new(self).direct(to_state, metadata: metadata, bypass_events: bypass_events, seen: seen)
12
12
  end
13
13
 
14
- def transition_to(to_state, metadata: {}, bypass_events: false)
15
- transition_to!(to_state, metadata: metadata, bypass_events: bypass_events)
14
+ def transition_to(to_state, metadata: {}, bypass_events: false, seen: nil)
15
+ transition_to!(to_state, metadata: metadata, bypass_events: bypass_events, seen: seen)
16
16
  rescue GuardFailed, InvalidTransition
17
17
  false
18
18
  end
19
19
 
20
- def fire!(event_name, metadata: {})
21
- Pipeline.new(self).fire(event_name, metadata: metadata)
20
+ def fire!(event_name, metadata: {}, seen: nil)
21
+ Pipeline.new(self).fire(event_name, metadata: metadata, seen: seen)
22
22
  end
23
23
 
24
- def fire(event_name, metadata: {})
25
- fire!(event_name, metadata: metadata)
24
+ def fire(event_name, metadata: {}, seen: nil)
25
+ fire!(event_name, metadata: metadata, seen: seen)
26
26
  rescue GuardFailed, InvalidTransition
27
27
  false
28
28
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ class Pipeline
5
+ # The tagged half of the CAS: when the mounting carries versioning, every
6
+ # transition compares-and-swaps on (state, version) and increments the
7
+ # version in the same UPDATE. A seen: token substitutes the compared
8
+ # value with what the caller's form actually rendered, and its refusal is
9
+ # StaleTransition — the 409 of the pipeline.
10
+ module Versioning
11
+ private
12
+
13
+ def version_column
14
+ column = configuration.version_column
15
+ return nil unless column
16
+
17
+ unless record.class.column_names.include?(column.to_s)
18
+ raise CompilationError,
19
+ "versioning column #{column} does not exist on #{record.class.table_name}; " \
20
+ "add the column or drop the versioning option"
21
+ end
22
+ column
23
+ end
24
+
25
+ def normalize_seen(raw_seen)
26
+ return nil if raw_seen.nil?
27
+
28
+ unless configuration.version_column
29
+ raise CompilationError,
30
+ "seen: requires versioning: true on the mounting of #{record.class.name}; " \
31
+ "add the option or drop the token"
32
+ end
33
+ Integer(raw_seen)
34
+ end
35
+
36
+ # What the CAS requires the row's version to be: the caller's seen:
37
+ # token when given, the in-memory value otherwise — fresh after a lock
38
+ # reload, synced by the previous link inside a chain.
39
+ def expected_version
40
+ @seen || record[version_column]
41
+ end
42
+
43
+ # With a seen: token any versioned refusal is staleness — the caller
44
+ # acted on a snapshot; without one it is the ordinary conflict of two
45
+ # writers.
46
+ def cas_refusal(edge)
47
+ if @seen
48
+ StaleTransition.new(record: record, expected_from: edge.from,
49
+ expected_version: expected_version, seen: @raw_seen)
50
+ else
51
+ TransitionConflict.new(record: record, expected_from: edge.from)
52
+ end
53
+ end
54
+
55
+ # The same early observation as assert_lock_saw_expected_state, for the
56
+ # seen: token: the reload holds the row's real version under the lock,
57
+ # so a mismatched token is a stale snapshot detected deterministically,
58
+ # before any write.
59
+ def assert_lock_saw_expected_version(edge)
60
+ return unless @seen && version_column
61
+ return if record[version_column] == @seen
62
+
63
+ raise StaleTransition.new(record: record, expected_from: edge.from,
64
+ expected_version: @seen, seen: @raw_seen)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -29,6 +29,7 @@ module Statecraft
29
29
  MAX_CHAIN_DEPTH = 16
30
30
 
31
31
  include EdgeResolution
32
+ include Versioning
32
33
 
33
34
  def self.transition_stack
34
35
  ActiveSupport::IsolatedExecutionState[STACK_KEY] ||= []
@@ -41,15 +42,15 @@ module Statecraft
41
42
  @machine_instance = @configuration.machine_class.new
42
43
  end
43
44
 
44
- def direct(to_state, metadata:, bypass_events:)
45
- run(metadata) do |current_state|
45
+ def direct(to_state, metadata:, bypass_events:, seen: nil)
46
+ run(metadata, seen: seen) do |current_state|
46
47
  edge = resolve_direct_edge(current_state, to_state.to_sym, bypass_events)
47
48
  [edge, nil, bypass_events]
48
49
  end
49
50
  end
50
51
 
51
- def fire(event_name, metadata:)
52
- run(metadata) do |current_state|
52
+ def fire(event_name, metadata:, seen: nil)
53
+ run(metadata, seen: seen) do |current_state|
53
54
  edge = resolve_event_edge(current_state, event_name.to_sym)
54
55
  [edge, event_name.to_sym, false]
55
56
  end
@@ -59,10 +60,12 @@ module Statecraft
59
60
 
60
61
  attr_reader :record, :configuration, :graph, :machine_instance
61
62
 
62
- def run(raw_metadata, &edge_resolver)
63
+ def run(raw_metadata, seen: nil, &edge_resolver)
63
64
  raise UnsavedRecordError.new(record: record) unless record.persisted?
64
65
 
65
66
  assert_single_column_primary_key
67
+ @raw_seen = seen
68
+ @seen = normalize_seen(seen)
66
69
  metadata = Metadata.normalize(raw_metadata)
67
70
  started_at = Time.current
68
71
  begin
@@ -91,6 +94,7 @@ module Statecraft
91
94
  case error
92
95
  when GuardFailed then [:guard_failed, { guard: error.guard }]
93
96
  when InvalidTransition then [:invalid_transition, { requested: error.requested }]
97
+ when StaleTransition then [:stale, { expected_version: error.expected_version, seen: error.seen }]
94
98
  when TransitionConflict then [:conflict, { expected_from: error.expected_from }]
95
99
  end
96
100
  Instrumentation.publish_failure(
@@ -105,6 +109,7 @@ module Statecraft
105
109
  warn_when_row_locking_unavailable
106
110
  record.reload(lock: true)
107
111
  assert_lock_saw_expected_state(edge)
112
+ assert_lock_saw_expected_version(edge)
108
113
  end
109
114
  transition_time = Time.current
110
115
  run_guards(edge, event, bypass, metadata)
@@ -204,17 +209,21 @@ module Statecraft
204
209
  end
205
210
 
206
211
  def cas_update!(edge, transition_time)
207
- affected_rows = base_class.unscoped
208
- .where(base_class.primary_key => record.id,
209
- configuration.column => edge.from.to_s)
210
- .update_all(cas_updates(edge, transition_time))
211
- raise TransitionConflict.new(record: record, expected_from: edge.from) if affected_rows.zero?
212
+ scope = base_class.unscoped
213
+ .where(base_class.primary_key => record.id,
214
+ configuration.column => edge.from.to_s)
215
+ scope = scope.where(version_column => expected_version) if version_column
216
+ affected_rows = scope.update_all(cas_updates(edge, transition_time))
217
+ raise cas_refusal(edge) if affected_rows.zero?
212
218
  end
213
219
 
214
220
  def cas_updates(edge, transition_time)
215
221
  updates = { configuration.column => edge.to.to_s }
216
222
  updates[:updated_at] = transition_time if touch_updated_at?
217
223
  updates[changed_at_column] = transition_time if changed_at_column
224
+ # The literal is correct by construction: the CAS WHERE guarantees the
225
+ # row held exactly expected_version, so the memory mirror stays exact.
226
+ updates[version_column] = expected_version + 1 if version_column
218
227
  updates
219
228
  end
220
229
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statecraft
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/statecraft.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "statecraft/instrumentation"
10
10
  require_relative "statecraft/machine"
11
11
  require_relative "statecraft/metadata"
12
12
  require_relative "statecraft/pipeline/edge_resolution"
13
+ require_relative "statecraft/pipeline/versioning"
13
14
  require_relative "statecraft/pipeline"
14
15
  require_relative "statecraft/pipeline/surface"
15
16
  require_relative "statecraft/introspection"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: statecraft
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Pugachev
@@ -87,6 +87,7 @@ files:
87
87
  - lib/statecraft/pipeline.rb
88
88
  - lib/statecraft/pipeline/edge_resolution.rb
89
89
  - lib/statecraft/pipeline/surface.rb
90
+ - lib/statecraft/pipeline/versioning.rb
90
91
  - lib/statecraft/rspec.rb
91
92
  - lib/statecraft/rspec/allow_event.rb
92
93
  - lib/statecraft/rspec/allow_transition_to.rb