statecraft 0.7.1 → 0.9.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: b91fc7675cab350149a2e58679893b18ccdfa779cc84e2eb5a7d28c5591c265c
4
- data.tar.gz: c751611f88af3a85738877185b68de734578a353818aa130c19206d40b498b82
3
+ metadata.gz: c8c55553745396ae74c5ffdac6de4dfad3df8ed075e26ef06ac627084aa87216
4
+ data.tar.gz: 995e759e6a394619f991b1f6921065219a9259928f4797aa4a340f66c601e0b7
5
5
  SHA512:
6
- metadata.gz: 42c744386532cf91f06374dc3695c4e2ee3e64602fbb428045e3b588c27dc32843959552dea944be5d98834f28036baf1d712da2c25008dba8b16766ca23310b
7
- data.tar.gz: 86822e6c1fba14b00e6a8e18efc586a7f92f35280490a168e4d96d063a6d3ecd471eac6fe877c512b9972359d1d14f4d344521dc6f0fea56235075df2bae975b
6
+ metadata.gz: 96794cd2d550849d99ccef2d9e6d0e12d12b790e585d7b8734a04a36ba55321f98bee6db237e9199e4c8ff02875900809070bc8fd295fb22dab7f5d41e71c87b
7
+ data.tar.gz: 6d5400fd71f2448099d9fc5c60ac71b663bbaed19b2c2d811e25172fa75148f970b9336b21fde31c09865f525dd428e2703314234bfb3e052c4dc1c5b2708085
data/README.md CHANGED
@@ -145,6 +145,16 @@ A good `record_guard:` is a one-line delegation to a domain predicate on the
145
145
  model (`def customer_cancellable?(record) = record.customer_cancellable?`):
146
146
  the machine keeps the registry "event → predicate", the model keeps the fact.
147
147
 
148
+ The third nature is `input_guard:` — a guard declaring that its answer
149
+ *without* the input would be a false "no". Its handler must take exactly
150
+ `(record, metadata)` (the compiler refuses any other arity), execution runs
151
+ it after the record and plain layers, and the question surface honors the
152
+ declaration: a `can_fire?` / `may_*?` / `available_*` asked with metadata
153
+ omitted entirely raises `Statecraft::MetadataRequired` naming the guard,
154
+ instead of feeding it an empty hash and predicting a refusal that the real
155
+ call would not hit. An explicit `metadata: {}` states "my input is empty"
156
+ and is always a legal question.
157
+
148
158
  Calling `transition_to!` directly over an edge that carries event guards is
149
159
  refused — the guards would be silently skipped. The escape hatch is explicit:
150
160
  `transition_to!(:paid, bypass_events: true)` skips event guards (edge guards
@@ -326,7 +336,36 @@ or a missing branch. It carries names, never words: human-readable reasons
326
336
  belong to the application's presentation layer, not to the machine.
327
337
 
328
338
  A guard that reads metadata makes `may_*?` depend on the metadata you pass —
329
- pass the same metadata to `may_*?` that you will collect for `fire!`.
339
+ pass the same metadata to `may_*?` that you will collect for `fire!`. Mark
340
+ such a guard `input_guard:` and the machine holds you to it: a question
341
+ asked without any `metadata:` raises `Statecraft::MetadataRequired` with
342
+ the guard's name instead of answering a false "no" (an explicit
343
+ `metadata: {}` stays legal — it states the input is genuinely empty). A
344
+ plain `guard:` keeps the old behavior: a bare question consults it with an
345
+ empty hash.
346
+
347
+ ## Strict mode
348
+
349
+ By default an unreachable state compiles silently — the column is written
350
+ by more than the gem (legacy rows, the console, external systems), so a
351
+ state without inbound edges is not necessarily a mistake. A machine that
352
+ claims its graph is closed opts in:
353
+
354
+ <!-- illustrative -->
355
+ ```ruby
356
+ class OrderFlow < ApplicationMachine
357
+ strict!
358
+
359
+ state :pending, initial: true
360
+ # every state below must now be reachable from :pending through edges
361
+ end
362
+ ```
363
+
364
+ With `strict!` compilation additionally requires every declared state to be
365
+ reachable from the initial one, walking edges only — guards are not
366
+ consulted, exactly like `transitions_from`. Dead ends stay legal in strict
367
+ mode too: terminal states are the norm, and an accidental one is surfaced
368
+ by the first test as `InvalidTransition` with the list of allowed targets.
330
369
 
331
370
  ## RSpec matchers
332
371
 
@@ -373,6 +412,20 @@ not with the block matcher.
373
412
  it names record-layer guards only. An input-reading `guard:` has no name
374
413
  there, and the failure message says so instead of guessing.
375
414
 
415
+ Under [`versioning:`](#stale-transitions-versioning-against-aba) the block
416
+ matcher folds the version into the same assertion — a matching transition
417
+ must also have incremented the version column — and every failure message
418
+ prints the record's standing with its version
419
+ (`Order in state :pending (state_version 3)`). Staleness stays an
420
+ exception, exactly as in production: assert the refusal of a stale token
421
+ with `raise_error`, not with a prediction matcher —
422
+
423
+ <!-- illustrative -->
424
+ ```ruby
425
+ expect { order.cancel!(seen: stale_token) }
426
+ .to raise_error(Statecraft::StaleTransition)
427
+ ```
428
+
376
429
  ## Metadata
377
430
 
378
431
  Metadata is normalized on pipeline entry with a full JSON round-trip —
@@ -621,9 +674,10 @@ class OrderFlow
621
674
  # event and the bypass path all share pending -> cancelled — the log
622
675
  # records HOW, not only WHAT. The cancel guards split by nature: the
623
676
  # record layer judges the order (and the offering may ask it), the input
624
- # layer judges what the operator typed (only fire! and the panel see it).
677
+ # layer judges what the operator typed and is marked input_guard:, so a
678
+ # question asked without metadata raises instead of predicting a false no.
625
679
  event :cancel, from: :pending, to: :cancelled,
626
- record_guard: :customer_cancellable?, guard: :reason_present?
680
+ record_guard: :customer_cancellable?, input_guard: :reason_present?
627
681
  event :admin_override, from: :pending, to: :cancelled
628
682
 
629
683
  private
@@ -139,4 +139,21 @@ module Statecraft
139
139
  "make the log model inherit from the model's connection-owning ancestor")
140
140
  end
141
141
  end
142
+
143
+ # A question (can_fire?, may_*?, available_*) was asked without metadata
144
+ # while an input_guard stands on the consulted path — its answer without
145
+ # the input would be a false "no", so the question refuses loudly instead.
146
+ # An explicit `metadata: {}` states "my input is empty" and is legal.
147
+ class MetadataRequired < Error
148
+ attr_reader :record, :question, :guards
149
+
150
+ def initialize(record:, question:, guards:)
151
+ @record = record
152
+ @question = question
153
+ @guards = guards
154
+ super("#{question} on #{record.class.name} consults input guards " \
155
+ "#{guards.map(&:inspect).join(", ")} — pass metadata: to answer with input " \
156
+ "(an explicit metadata: {} means the input is empty)")
157
+ end
158
+ end
142
159
  end
@@ -11,7 +11,7 @@ module Statecraft
11
11
  Availability = Struct.new(:to, :via, keyword_init: true)
12
12
  Refusal = Struct.new(:event, :guard, :layer, keyword_init: true)
13
13
 
14
- def can_fire?(event_name, metadata: {})
14
+ def can_fire?(event_name, metadata: Metadata::OMITTED)
15
15
  graph = statecraft_graph
16
16
  branches = graph.events[event_name.to_sym]
17
17
  return false unless branches
@@ -19,24 +19,32 @@ module Statecraft
19
19
  edge = branches[statecraft_current_state]
20
20
  return false unless edge
21
21
 
22
- statecraft_guards_pass?(edge, event_name.to_sym, Metadata.normalize(metadata))
22
+ normalized = statecraft_question_metadata(metadata, [[edge, event_name.to_sym]],
23
+ question: "can_fire?(#{event_name.inspect})")
24
+ statecraft_guards_pass?(edge, event_name.to_sym, normalized)
23
25
  end
24
26
 
25
- def available_events(metadata: {})
26
- normalized = Metadata.normalize(metadata)
27
- statecraft_graph.events.filter_map do |event_name, branches|
27
+ def available_events(metadata: Metadata::OMITTED)
28
+ consulted = statecraft_graph.events.filter_map do |event_name, branches|
28
29
  edge = branches[statecraft_current_state]
29
- next unless edge
30
-
30
+ [edge, event_name] if edge
31
+ end
32
+ normalized = statecraft_question_metadata(metadata, consulted, question: "available_events")
33
+ consulted.filter_map do |edge, event_name|
31
34
  event_name if statecraft_guards_pass?(edge, event_name, normalized)
32
35
  end
33
36
  end
34
37
 
35
- def available_transitions(metadata: {})
36
- normalized = Metadata.normalize(metadata)
37
- statecraft_graph.edges.filter_map do |(from, _to), edge|
38
- next unless from == statecraft_current_state
39
-
38
+ def available_transitions(metadata: Metadata::OMITTED)
39
+ outgoing = statecraft_graph.edges.filter_map do |(from, _to), edge|
40
+ edge if from == statecraft_current_state
41
+ end
42
+ consulted = outgoing.flat_map do |edge|
43
+ pairs = edge.event_names.map { |event_name| [edge, event_name] }
44
+ statecraft_direct_legal?(edge) ? pairs + [[edge, nil]] : pairs
45
+ end
46
+ normalized = statecraft_question_metadata(metadata, consulted, question: "available_transitions")
47
+ outgoing.filter_map do |edge|
40
48
  via = statecraft_passable_via(edge, normalized)
41
49
  Availability.new(to: edge.to, via: via) unless via.empty?
42
50
  end
@@ -73,6 +81,23 @@ module Statecraft
73
81
 
74
82
  private
75
83
 
84
+ # The single funnel for a question's metadata. Omitted with no input
85
+ # guard on the consulted path degrades to an empty hash; omitted with
86
+ # one raises MetadataRequired — the guard declared that its answer
87
+ # without input would be a false "no". An explicit hash always passes.
88
+ def statecraft_question_metadata(metadata, consulted_pairs, question:)
89
+ return Metadata.normalize(metadata) unless metadata.equal?(Metadata::OMITTED)
90
+
91
+ input_guards = consulted_pairs.flat_map { |edge, event_name| statecraft_input_guards(edge, event_name) }
92
+ return Metadata.normalize({}) if input_guards.empty?
93
+
94
+ raise MetadataRequired.new(record: self, question: question, guards: input_guards.uniq)
95
+ end
96
+
97
+ def statecraft_input_guards(edge, event_name)
98
+ edge.edge_input_guards + (event_name ? edge.event_input_guards.fetch(event_name, []) : [])
99
+ end
100
+
76
101
  def statecraft_record_refusals(edge, event_name)
77
102
  machine_instance = self.class.statecraft_mounting.machine_class.new
78
103
  layers = {
@@ -7,12 +7,15 @@ module Statecraft
7
7
  # Guard and callback symbols resolve to instance methods of the machine
8
8
  # class; callables are honored with a plain `call` and arity dispatch.
9
9
  module Machine
10
- # edge_guards / event_guards carry the FULL lists of both layers in
11
- # record-then-input order — execution and the prediction run them as one
10
+ # edge_guards / event_guards carry the FULL lists of all layers in
11
+ # record-plain-input order — execution and the prediction run them as one
12
12
  # sequence. The parallel *_record_guards fields hold only the record
13
- # layer; nothing but the offering introspection reads them.
14
- Edge = Struct.new(:from, :to, :lock, :edge_guards, :edge_record_guards,
15
- :event_names, :event_guards, :event_record_guards, keyword_init: true)
13
+ # layer (read by the offering introspection); the *_input_guards fields
14
+ # hold only the marked input layer (read by the question surface to
15
+ # refuse a question asked without metadata).
16
+ Edge = Struct.new(:from, :to, :lock, :edge_guards, :edge_record_guards, :edge_input_guards,
17
+ :event_names, :event_guards, :event_record_guards, :event_input_guards,
18
+ keyword_init: true)
16
19
  Callback = Struct.new(:handler, :from, :to, :event, keyword_init: true)
17
20
  CompiledGraph = Struct.new(
18
21
  :states, :initial_state, :edges, :events, :callbacks,
@@ -59,16 +62,17 @@ module Statecraft
59
62
  declared_states << { name: name.to_sym, initial: initial }
60
63
  end
61
64
 
62
- def transition(from:, to:, guard: nil, record_guard: nil, lock: false)
65
+ def transition(from:, to:, guard: nil, record_guard: nil, input_guard: nil, lock: false)
63
66
  declared_edges << {
64
67
  from: from.to_sym, to: to.to_sym,
65
68
  guards: Array(guard), record_guards: Array(record_guard),
69
+ input_guards: Array(input_guard),
66
70
  lock: lock || current_event_lock,
67
71
  event: current_event_name
68
72
  }
69
73
  end
70
74
 
71
- def event(name, from: nil, to: nil, guard: nil, record_guard: nil, lock: false, &declarations)
75
+ def event(name, from: nil, to: nil, guard: nil, record_guard: nil, input_guard: nil, lock: false, &declarations)
72
76
  name = name.to_sym
73
77
  declared_event_names << name
74
78
  if declarations
@@ -87,7 +91,8 @@ module Statecraft
87
91
 
88
92
  @statecraft_current_event = { name: name, lock: false }
89
93
  begin
90
- transition(from: from, to: to, guard: guard, record_guard: record_guard, lock: lock)
94
+ transition(from: from, to: to, guard: guard, record_guard: record_guard,
95
+ input_guard: input_guard, lock: lock)
91
96
  ensure
92
97
  @statecraft_current_event = nil
93
98
  end
@@ -108,6 +113,16 @@ module Statecraft
108
113
  end
109
114
  end
110
115
 
116
+ # Opt-in shape strictness: with strict! the compiler additionally
117
+ # requires every declared state to be reachable from the initial one.
118
+ # Off by default — the column is written by more than the gem, so an
119
+ # unconnected state is legal unless the machine claims a closed graph.
120
+ def strict!
121
+ @statecraft_strict = true
122
+ end
123
+
124
+ def strict? = @statecraft_strict == true
125
+
111
126
  def states
112
127
  compiled_graph.states
113
128
  end
@@ -197,6 +212,8 @@ module Statecraft
197
212
  events = compile_events(edges)
198
213
  resolve_symbols(edges)
199
214
  assert_record_guards_unary(edges)
215
+ assert_input_guards_binary(edges)
216
+ assert_states_reachable(states, initial, edges) if machine_class.strict?
200
217
  CompiledGraph.new(
201
218
  states: states.freeze,
202
219
  initial_state: initial,
@@ -249,9 +266,10 @@ module Statecraft
249
266
  def build_edge(declaration)
250
267
  Edge.new(
251
268
  from: declaration[:from], to: declaration[:to], lock: declaration[:lock],
252
- edge_guards: declaration[:record_guards] + declaration[:guards],
269
+ edge_guards: declaration[:record_guards] + declaration[:guards] + declaration[:input_guards],
253
270
  edge_record_guards: declaration[:record_guards],
254
- event_names: [], event_guards: {}, event_record_guards: {}
271
+ edge_input_guards: declaration[:input_guards],
272
+ event_names: [], event_guards: {}, event_record_guards: {}, event_input_guards: {}
255
273
  )
256
274
  end
257
275
 
@@ -259,16 +277,18 @@ module Statecraft
259
277
  event_name = declaration[:event]
260
278
  edge ||= Edge.new(
261
279
  from: declaration[:from], to: declaration[:to], lock: false,
262
- edge_guards: [], edge_record_guards: [],
263
- event_names: [], event_guards: {}, event_record_guards: {}
280
+ edge_guards: [], edge_record_guards: [], edge_input_guards: [],
281
+ event_names: [], event_guards: {}, event_record_guards: {}, event_input_guards: {}
264
282
  )
265
283
  if edge.event_names.include?(event_name)
266
284
  raise CompilationError, "event #{event_name.inspect} declares edge #{pair_name(pair)} twice"
267
285
  end
268
286
 
269
287
  edge.event_names << event_name
270
- edge.event_guards[event_name] = declaration[:record_guards] + declaration[:guards]
288
+ edge.event_guards[event_name] = declaration[:record_guards] + declaration[:guards] +
289
+ declaration[:input_guards]
271
290
  edge.event_record_guards[event_name] = declaration[:record_guards]
291
+ edge.event_input_guards[event_name] = declaration[:input_guards]
272
292
  edge.lock ||= declaration[:lock]
273
293
  edge
274
294
  end
@@ -334,15 +354,62 @@ module Statecraft
334
354
  guard.respond_to?(:arity) ? guard.arity : guard.method(:call).arity
335
355
  end
336
356
 
357
+ # An input guard promises that its answer is meaningless without the
358
+ # input, and the promise is held by shape: it must accept exactly the
359
+ # record and the metadata, so a question asked without metadata can be
360
+ # refused instead of answered falsely.
361
+ def assert_input_guards_binary(edges)
362
+ edges.each_value do |edge|
363
+ input_guards = edge.edge_input_guards + edge.event_input_guards.values.flatten
364
+ input_guards.each do |guard|
365
+ arity = record_guard_arity(guard)
366
+ next if arity == 2
367
+
368
+ label = guard.is_a?(Symbol) ? guard.inspect : "the callable"
369
+ raise CompilationError,
370
+ "input_guard #{label} must take the record and the metadata (arity 2), got arity #{arity}"
371
+ end
372
+ end
373
+ end
374
+
375
+ # Strict reachability is a shape check, like transitions_from: edges
376
+ # only, no guards. Dead ends stay legal — terminal states are the norm.
377
+ def assert_states_reachable(states, initial, edges)
378
+ unreachable = states - reachable_states(initial, edges)
379
+ return if unreachable.empty?
380
+
381
+ raise CompilationError,
382
+ "strict!: unreachable from the initial #{initial.inspect}: " \
383
+ "#{unreachable.map(&:inspect).join(", ")} — connect with edges or drop strict!"
384
+ end
385
+
386
+ def reachable_states(initial, edges)
387
+ reached = [initial]
388
+ queue = [initial]
389
+ until queue.empty?
390
+ from = queue.shift
391
+ edges.each_key do |(edge_from, edge_to)|
392
+ next if edge_from != from || reached.include?(edge_to)
393
+
394
+ reached << edge_to
395
+ queue << edge_to
396
+ end
397
+ end
398
+ reached
399
+ end
400
+
337
401
  def deep_freeze_edges(edges)
338
402
  edges.each_value do |edge|
339
403
  edge.edge_guards.freeze
340
404
  edge.edge_record_guards.freeze
405
+ edge.edge_input_guards.freeze
341
406
  edge.event_names.freeze
342
407
  edge.event_guards.each_value(&:freeze)
343
408
  edge.event_guards.freeze
344
409
  edge.event_record_guards.each_value(&:freeze)
345
410
  edge.event_record_guards.freeze
411
+ edge.event_input_guards.each_value(&:freeze)
412
+ edge.event_input_guards.freeze
346
413
  edge.freeze
347
414
  end
348
415
  edges.freeze
@@ -7,6 +7,11 @@ module Statecraft
7
7
  # strings, and anything that JSON cannot represent fails instantly at the
8
8
  # pipeline entrance instead of inside the transaction.
9
9
  module Metadata
10
+ # The question surface's default: distinguishes "asked without metadata"
11
+ # from an explicit empty hash, so a question that would consult an
12
+ # input_guard can refuse loudly instead of answering from a void.
13
+ OMITTED = Object.new.freeze
14
+
10
15
  def self.normalize(raw_metadata)
11
16
  deep_freeze(round_trip(raw_metadata))
12
17
  end
@@ -183,7 +183,9 @@ module Statecraft
183
183
  define_method(event_name) do |metadata: {}, seen: nil|
184
184
  fire(event_name, metadata: metadata, seen: seen)
185
185
  end
186
- define_method("may_#{event_name}?") { |metadata: {}| can_fire?(event_name, metadata: metadata) }
186
+ define_method("may_#{event_name}?") do |metadata: Metadata::OMITTED|
187
+ can_fire?(event_name, metadata: metadata)
188
+ end
187
189
  end
188
190
  end
189
191
  model.include(verbs)
@@ -14,7 +14,12 @@ module Statecraft
14
14
  end
15
15
 
16
16
  def standing(record)
17
- "#{record.class.name} in state #{current_state(record).inspect}"
17
+ "#{record.class.name} in state #{current_state(record).inspect}#{version_standing(record)}"
18
+ end
19
+
20
+ def version_standing(record)
21
+ column = record.class.statecraft_mounting.version_column
22
+ column ? " (#{column} #{record[column]})" : ""
18
23
  end
19
24
 
20
25
  def machine(record)
@@ -7,7 +7,8 @@ module Statecraft
7
7
  #
8
8
  # The transition through the eyes of a test: the state column moved to
9
9
  # the target AND exactly one log row was appended with the matching
10
- # from/to/event/metadata. A non-bang call that returned false leaves
10
+ # from/to/event/metadata and under versioning: the version column
11
+ # incremented together with the state. A non-bang call that returned false leaves
11
12
  # both untouched — the matcher fails and explains why, from the same
12
13
  # introspection the pipeline consulted. Exceptions of the bang forms
13
14
  # fly through, like with the change matcher: refusals are asserted
@@ -45,10 +46,13 @@ module Statecraft
45
46
  def matches?(block)
46
47
  raise ArgumentError, "transition(record).to(:state) — the .to target is required" unless @to_state
47
48
 
49
+ @version_column = @record.class.statecraft_mounting.version_column
48
50
  @before_state = StateReport.current_state(@record)
51
+ @before_version = @record[@version_column] if @version_column
49
52
  appended_before = @record.history.count
50
53
  block.call
51
54
  @after_state = StateReport.current_state(@record)
55
+ @after_version = @record[@version_column] if @version_column
52
56
  @appended = @record.history.offset(appended_before).to_a
53
57
  collect_failures
54
58
  @failures.empty?
@@ -84,6 +88,15 @@ module Statecraft
84
88
  @failures << "the record ended in #{@after_state.inspect}, not #{@to_state.inspect}"
85
89
  end
86
90
  collect_row_mismatches(@appended.last)
91
+ collect_version_mismatch
92
+ end
93
+
94
+ def collect_version_mismatch
95
+ return unless @version_column
96
+ return if @after_version == @before_version + 1
97
+
98
+ @failures << "the #{@version_column} column went from #{@before_version} to #{@after_version}, " \
99
+ "expected #{@before_version + 1}: a versioned transition increments the version with the state"
87
100
  end
88
101
 
89
102
  def collect_row_mismatches(row)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statecraft
4
- VERSION = "0.7.1"
4
+ VERSION = "0.9.0"
5
5
  end
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.7.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Pugachev