actionable-rb 1.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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.claude-plugin/marketplace.json +15 -0
  3. data/.claude-plugin/plugin.json +9 -0
  4. data/.rspec +3 -0
  5. data/AGENTS.md +150 -0
  6. data/CHANGELOG.md +766 -0
  7. data/CODE_OF_CONDUCT.md +132 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +555 -0
  10. data/UPGRADING.md +957 -0
  11. data/USAGE.md +1291 -0
  12. data/docs/getting_started.md +611 -0
  13. data/lib/actionable/action.rb +1220 -0
  14. data/lib/actionable/batch_result.rb +92 -0
  15. data/lib/actionable/errors.rb +36 -0
  16. data/lib/actionable/history.rb +91 -0
  17. data/lib/actionable/input_dispatch.rb +94 -0
  18. data/lib/actionable/job.rb +157 -0
  19. data/lib/actionable/measurement.rb +64 -0
  20. data/lib/actionable/rails/after_commit.rb +142 -0
  21. data/lib/actionable/rails/proxy_validator.rb +59 -0
  22. data/lib/actionable/rails/transactions.rb +95 -0
  23. data/lib/actionable/rails.rb +24 -0
  24. data/lib/actionable/railtie.rb +10 -0
  25. data/lib/actionable/rbs.rb +502 -0
  26. data/lib/actionable/registry.rb +64 -0
  27. data/lib/actionable/result.rb +166 -0
  28. data/lib/actionable/results/failure.rb +43 -0
  29. data/lib/actionable/results/skipped.rb +30 -0
  30. data/lib/actionable/results/success.rb +14 -0
  31. data/lib/actionable/rspec/coverage/report.rb +264 -0
  32. data/lib/actionable/rspec/coverage/tracker.rb +330 -0
  33. data/lib/actionable/rspec/coverage.rb +45 -0
  34. data/lib/actionable/rspec/matchers.rb +266 -0
  35. data/lib/actionable/rspec/stubs.rb +250 -0
  36. data/lib/actionable/rspec.rb +40 -0
  37. data/lib/actionable/rubocop/config.yml +8 -0
  38. data/lib/actionable/rubocop/cop/actionable/verb_not_last.rb +62 -0
  39. data/lib/actionable/rubocop/plugin.rb +31 -0
  40. data/lib/actionable/rubocop.rb +25 -0
  41. data/lib/actionable/runner.rb +300 -0
  42. data/lib/actionable/steps/action.rb +76 -0
  43. data/lib/actionable/steps/base.rb +107 -0
  44. data/lib/actionable/steps/case.rb +124 -0
  45. data/lib/actionable/steps/method.rb +39 -0
  46. data/lib/actionable/steps.rb +27 -0
  47. data/lib/actionable/value_match.rb +22 -0
  48. data/lib/actionable/verb_position.rb +574 -0
  49. data/lib/actionable/version.rb +6 -0
  50. data/lib/actionable-rb.rb +6 -0
  51. data/lib/actionable.rb +59 -0
  52. data/sig/actionable/dsl.rbs +42 -0
  53. data/sig/actionable/rails.rbs +16 -0
  54. data/sig/actionable.rbs +2593 -0
  55. data/skills/actionable/SKILL.md +214 -0
  56. metadata +132 -0
data/USAGE.md ADDED
@@ -0,0 +1,1291 @@
1
+ # Actionable — Usage
2
+
3
+ A dense, example-first reference. Every block marked for doctesting is executed
4
+ by `spec/docs_examples_spec.rb`, so it can't rot. For a gentler introduction see
5
+ the [README](README.md).
6
+
7
+ ---
8
+
9
+ ## Results
10
+
11
+ A run returns one of three FieldStruct-backed value objects. They can be built
12
+ directly (handy in tests):
13
+
14
+ <!-- doctest -->
15
+ ```ruby
16
+ ok = Actionable::Success.new(message: 'done')
17
+ ok.success? # => true
18
+ ok.ok? # => true
19
+
20
+ skipped = Actionable::Skipped.new(code: :not_ready)
21
+ skipped.skipped? # => true
22
+ skipped.failure? # => false
23
+ skipped.ok? # => true
24
+
25
+ bad = Actionable::Failure.new(code: :nope)
26
+ bad.failure? # => true
27
+ bad.ok? # => false
28
+ ```
29
+
30
+ Every result carries `code`, `message`, `errors`, `output`, and `history`.
31
+ Predicates: `success?`/`successful?`, `failure?`/`failed?`, `skipped?`, and `ok?`
32
+ (≡ `!failure?`). `to_s`/`inspect` are compact, field-sorted, and deterministic.
33
+
34
+ ## Steps
35
+
36
+ Declare steps in order; each names an instance method. Steps are kept in a
37
+ per-class `Set` keyed by `[type, name]`, so a redeclared step collapses to one
38
+ entry, and the set is inherited by subclasses.
39
+
40
+ Redeclaring a step replaces it: **the last declaration wins and keeps the first
41
+ one's position**. A subclass can override an inherited step's guards or options
42
+ this way without touching the parent's steps.
43
+
44
+ <!-- doctest -->
45
+ ```ruby
46
+ class Checkout < Actionable::Action
47
+ step :load
48
+ step :charge
49
+ end
50
+
51
+ class DryCheckout < Checkout
52
+ step :charge, if: :live? # replaces Checkout's :charge, still after :load
53
+ end
54
+
55
+ DryCheckout.steps.map(&:name) # => [:load, :charge]
56
+ DryCheckout.steps.to_a.last.options # => {if: :live?}
57
+ Checkout.steps.to_a.last.options # => {}
58
+ ```
59
+
60
+ | Redeclared with | Result |
61
+ |---|---|
62
+ | `step :x` then `step :x, if: :ok?` (same class or a subclass) | one `:x` step, guarded by `:ok?`, at the first `:x`'s position |
63
+ | `step Child` then `step Child, expose: []` | one nested step with the last `:input`/`:expose` |
64
+ | `case_step(:x) { … }` then `case_step(:x) { … }` | one case step with the last block's branches |
65
+ | `on_success :x` then `on_success :x, if: :ok?` (likewise `on_failure` / `on_skip` / `always`) | one hook with the last options, at the first one's position |
66
+ | `step :x` and `case_step :x` | **two** steps — a method step and a case step are different types |
67
+
68
+ Each hook phase is its own list, so `on_success :x` and `always :x` are two
69
+ hooks. There is no way to remove an inherited step or hook; guard it off with
70
+ `if:` / `unless:` instead.
71
+
72
+ ```ruby
73
+ class Example < Actionable::Action
74
+ step :first
75
+ step :second, if: :ready? # :if / :unless guards (Symbol or callable)
76
+ step :third, unless: :skip_it?
77
+ end
78
+ ```
79
+
80
+ Guards take a Symbol naming a (possibly private) predicate, or a callable
81
+ `->(instance) { … }`. Step, hook and `case_step` methods may be private too.
82
+ `@result` is reserved for the run's result: a step that assigns it anything
83
+ else raises `Actionable::DefinitionError`.
84
+
85
+ ## Control flow
86
+
87
+ | Verb | Records | Halts? | Returns |
88
+ |------|---------|--------|---------|
89
+ | `succeed(message = nil, **output)` | `Success` | no | `true` |
90
+ | `fail(code, message = nil, **errors)` | `Failure` | no | `false` |
91
+ | `fail_with(source, code: :invalid, message: nil)` | `Failure` absorbing `source.errors` | no | `false` |
92
+ | `skip(code = :skipped, message = nil, **output)` | `Skipped` | no | `false` |
93
+ | `succeed!` / `fail!` / `fail_with!` / `skip!` | as above | **yes** | — |
94
+ | `halt!` | keeps current `@result` | **yes** | — |
95
+
96
+ Plain (non-bang) verbs don't stop the pipeline — the final result reflects the
97
+ most recent record:
98
+
99
+ <!-- doctest -->
100
+ ```ruby
101
+ class LastWins < Actionable::Action
102
+ step :a
103
+ step :b
104
+ def a = fail(:nope)
105
+ def b = succeed('recovered')
106
+ end
107
+
108
+ LastWins.run.success? # => true
109
+ ```
110
+
111
+ `fail`'s keyword arguments populate the result's `errors`; `succeed`'s populate
112
+ its `output`. A raised `StandardError` is never caught — it propagates to the
113
+ caller (assert on it with the RSpec matcher's `and_raise`).
114
+
115
+ **`fail` is not `raise` inside an action.** `Actionable::Action#fail` replaces
116
+ Ruby's `Kernel#fail` (the alias of `raise`) in steps, hooks and any instance
117
+ method of the action, so `fail 'something broke'` cannot raise. A `fail` /
118
+ `skip` code must be an identifier: letters, digits and underscores, not
119
+ starting with a digit, optionally ending in `?` or `!` (`:card_expired`,
120
+ `:ok?`, `'NotFound'`). A sentence, a dashed or dotted string, or a class
121
+ raises `ArgumentError` at the call, naming the action and suggesting `raise`.
122
+ To signal a bug, use `raise`; to stop with a failure, use
123
+ `fail!(:code, 'message')`:
124
+
125
+ ```ruby
126
+ class Habit < Actionable::Action
127
+ step :check
128
+ def check = fail('card expired') # meant: raise
129
+ end
130
+
131
+ Habit.run # ArgumentError: Habit: failure code "card expired" is not an identifier; ...
132
+ ```
133
+
134
+ <!-- doctest -->
135
+ ```ruby
136
+ class Fixed < Actionable::Action
137
+ step :check
138
+ step :finish
139
+ def check = fail!(:card_expired, 'card expired') # records and halts
140
+ def finish = succeed
141
+ end
142
+
143
+ Fixed.run.code # => :card_expired
144
+ ```
145
+
146
+ A one-word string (`fail 'boom'`) is a valid code, so it still records a
147
+ failure `:boom` instead of raising. Code outside the action (a plain helper
148
+ object, a model) still gets `Kernel#fail`.
149
+
150
+ `fail_with` absorbs an arbitrary FieldStruct's validation errors into the failure
151
+ — the same machinery `:invalid_input` / `:invalid_output` use internally, exposed
152
+ as a verb for validating a side struct:
153
+
154
+ <!-- doctest -->
155
+ ```ruby
156
+ EventShape = Class.new(FieldStruct::Base) { required :event_type, :string }
157
+
158
+ class Ingest < Actionable::Action
159
+ step :validate
160
+ def validate = fail_with!(EventShape.new, code: :invalid_event)
161
+ end
162
+
163
+ result = Ingest.run
164
+ result.code # => :invalid_event
165
+ result.errors[:event_type] # => ["is required"]
166
+ ```
167
+
168
+ `skip` takes output kwargs too (symmetry with `succeed`) — captured best-effort,
169
+ never validated — so an idempotent hit can return the existing record's data:
170
+
171
+ <!-- doctest -->
172
+ ```ruby
173
+ class Dedup < Actionable::Action
174
+ output { optional :existing_id, :integer }
175
+ step :check
176
+ def check = skip!(:duplicate, 'already processed', existing_id: 42)
177
+ end
178
+
179
+ result = Dedup.run
180
+ result.skipped? # => true
181
+ result.existing_id # => 42
182
+ ```
183
+
184
+ ### Where a plain verb may go
185
+
186
+ `fail`, `fail_with`, `skip` and `succeed` record an outcome **and keep
187
+ running**, so in `fail :card_expired if card.expired?` followed by `notify`,
188
+ the `notify` still runs (`fail!` was meant). The rule: **in a step or hook method, a plain
189
+ recording verb must be the method's last expression, or explicitly end it.**
190
+ Bang verbs (`fail!`, `skip!`, `succeed!`, `fail_with!`) can go anywhere.
191
+
192
+ On an action's first run, every step method (case-step branch targets
193
+ included), lifecycle hook method (`on_success` / `on_failure` / `on_skip` /
194
+ `always`, and `after_commit` with the Rails adapter) is checked by parsing its
195
+ source with Prism. A misplaced verb raises `DefinitionError` naming the action,
196
+ method, `file:line` and verb, with the fix ("use fail! to stop here, or make it
197
+ the method's last expression"). The check runs once per class (a later run pays
198
+ nothing for it), and a failing check raises on every run until fixed. It is
199
+ skipped where it can't be done: no Prism (Ruby 3.2 without the `prism` gem; it
200
+ is a default gem from 3.3), or no readable source (a method from `eval` or
201
+ `define_method`). A step naming an `alias_method` is checked against the
202
+ original `def` (the cop doesn't follow aliases). A module `prepend`ed over a step method hides the class's own
203
+ `def` from the check: the wrapper is what runs first, so only its body is read
204
+ (the cop still reads the class's `def`).
205
+
206
+ | The verb call (`fail`, `fail_with`, `skip`, `succeed`, or `self.fail` …) | |
207
+ |---|---|
208
+ | is the method's last statement (a modifier `fail :x if bad?` counts as the statement) | OK |
209
+ | is the last statement of an `if` / `elsif` / `else` / `unless` / ternary / `case`-`when` / `case`-`in` / `rescue` / `ensure` branch, or the right side of `&&` / `and` / `\|\|` / `or`, whose construct is itself last | OK |
210
+ | is returned: `return fail(:x) if bad?`, or followed directly by `return` | OK |
211
+ | is the last statement of a block or loop body (or followed directly by `next` / `break` there), and the block's call is itself last: `items.each { \|i\| fail :bad if i.bad? }` | OK |
212
+ | is followed by an `ensure` (cleanup runs even after a bang verb) | OK |
213
+ | has another receiver (`gateway.fail`, `items.skip(1)`) | not checked |
214
+ | is followed by more code in the method, a branch whose construct is followed by more code, or a begin body that has an `else` | **offense** |
215
+ | is the end of a block or loop body whose call is followed by more code: `items.each { … fail :bad … }; save` | **offense** |
216
+ | is the left side of `and` / `or`: `fail(:x) and return` never returns (`fail` returns `false`); write `return fail(:x)` | **offense** |
217
+ | is nested in a larger expression: `log(fail(:x))`, `@ok = fail(:x)` | **offense** |
218
+
219
+ Methods that aren't steps or hooks (helpers they call) are not checked.
220
+
221
+ **Turning the first-run check off.** It is on by default. As an escape hatch
222
+ while upgrading (an app with misplaced verbs you can't fix yet), switch it off
223
+ for the whole process, e.g. in an initializer:
224
+
225
+ ```ruby
226
+ Actionable.verb_position_check = false # Actionable.verb_position_check? reads it back
227
+ ```
228
+
229
+ Off, `run` never parses a method's source and never loads Prism. **The
230
+ `Actionable/VerbNotLast` cop is then the only guard**, so run it in CI until the
231
+ switch is back on. A class that ran while the check was off is checked on its
232
+ first run after it's turned back on. The switch doesn't cover the code check at
233
+ the `fail` / `skip` call (sentence codes raise `ArgumentError`): that check is a
234
+ per-call regex match, not a parse, costs about 0.4 µs, and catches a wrong
235
+ result code rather than a style problem.
236
+
237
+ **The RuboCop cop.** The gem ships `Actionable/VerbNotLast`, the same rules as
238
+ a lint, *not* loaded by `require 'actionable'`. Opt in from `.rubocop.yml`:
239
+
240
+ ```yaml
241
+ plugins:
242
+ - actionable-rb:
243
+ require_path: actionable/rubocop
244
+ ```
245
+
246
+ (or `require: [actionable/rubocop]` on RuboCop < 1.72). The cop only sees one
247
+ file: it checks a class that is an action and declares `step` / `case_step`,
248
+ and the methods that class names in a step, case-step branch or hook
249
+ declaration. Methods a parent class declares as steps are left to the
250
+ first-run check.
251
+
252
+ A class counts as an action when its superclass is in `ActionBaseClasses`
253
+ (full names; default `Actionable::Action` and `ApplicationAction`; a leading
254
+ `::` is ignored), or is a class defined in the same file (`class Base < …` or
255
+ `Base = Class.new(…)`, any depth) whose own superclass does. The superclass
256
+ name resolves the way Ruby looks it up: from the enclosing `module` / `class`
257
+ outwards (a compact `class A::B` opens only `A::B`), to the first class,
258
+ module or listed base by that name, else the top level. So `Base` inside
259
+ `module B` is `B::Base` when the file defines one, not an `A::Base` action,
260
+ and a file's own `Legacy::ApplicationAction` shadows the listed one. Any
261
+ other class is left alone, so a dry-transaction
262
+ (`include Dry::Transaction` with its own `step`) gets no false positives, and
263
+ neither does a class that reopens an action without naming its superclass
264
+ (the first-run check still covers it). Name your app's own base classes:
265
+
266
+ ```yaml
267
+ Actionable/VerbNotLast:
268
+ ActionBaseClasses:
269
+ - Actionable::Action
270
+ - ApplicationAction
271
+ - Billing::BaseAction
272
+ ```
273
+
274
+ The list replaces the default, so keep the two defaults in it if you need them.
275
+
276
+ ## Declared failures
277
+
278
+ `fails(code, message = nil, **metadata)` declares one failure code an action can
279
+ end with. The keywords after the message are neutral per-code metadata: the gem
280
+ stores them, attaches them to the failure, and shows them in `describe`, but
281
+ never reads them. Your app gives the keys their meaning. A `fail` or `fail_with`
282
+ with a declared code carries its metadata as `result.metadata`, and its declared
283
+ message when the call gives none. `declared_failures` reads the declarations
284
+ back as `{code => metadata}`, and `failure_declarations` as
285
+ `{code => {message:, metadata:}}`:
286
+
287
+ <!-- doctest -->
288
+ ```ruby
289
+ class Charge < Actionable::Action
290
+ fails :refused, 'the card was refused', kind: :rejected
291
+ fails :not_found, kind: :not_found
292
+
293
+ step :charge
294
+ def charge = fail!(:refused, nil, kind: 'is not a known kind')
295
+ end
296
+
297
+ result = Charge.run
298
+ result.code # => :refused
299
+ result.message # => "the card was refused"
300
+ result.metadata # => {kind: :rejected}
301
+ result.errors[:kind] # => ["is not a known kind"]
302
+ Charge.declared_failures # => {refused: {kind: :rejected}, not_found: {kind: :not_found}}
303
+ ```
304
+
305
+ Nothing but `**errors` follows `fail`'s message, so an error on a field named
306
+ like a metadata key (`kind` above) stays an error. Declarations are inherited,
307
+ and a subclass can add more. Declaring `:invalid_input` or `:invalid_output`
308
+ attaches that metadata to the failures the gem builds itself. A failure whose
309
+ code isn't declared has `metadata == {}`, and `Failure#to_h` includes it.
310
+
311
+ **Strict once declared.** An action that declares at least one code may only fail
312
+ with codes it declares (`allows_failure_code?(code)` answers whether it may). Any other `fail` / `fail!` / `fail_with` / `fail_with!`
313
+ raises `Actionable::DefinitionError` from the call itself, naming the code and
314
+ the declared list:
315
+
316
+ <!-- doctest -->
317
+ ```ruby
318
+ class Refund < Actionable::Action
319
+ fails :refused, kind: :rejected
320
+ step :go
321
+ def go = fail!(:timeout)
322
+ end
323
+
324
+ begin
325
+ Refund.run
326
+ rescue Actionable::DefinitionError => e
327
+ e.message.include?('fails with :timeout, which it does not declare') # => true
328
+ end
329
+ ```
330
+
331
+ Two cases stay unchecked, so existing actions keep working unchanged:
332
+
333
+ - an action that declares no failures fails with any code, as before;
334
+ - the codes the gem emits itself, `Actionable::Action::GEM_FAILURE_CODES`
335
+ (`:invalid_input` and `:invalid_output`), are always allowed.
336
+
337
+ `fail_with`'s default code `:invalid` is not exempt: the action writes that
338
+ `fail_with`, so a declaring action declares `:invalid` or passes its own `code:`.
339
+
340
+ ## Declared skips
341
+
342
+ `skips(code, message = nil, **metadata)` declares one skip code an action can end
343
+ with, so skips get the same contract as failures (decision D21). The metadata is
344
+ neutral, as with `fails`. A `skip` or `skip!` with a declared code
345
+ carries its metadata as `result.metadata`, and its declared message when the
346
+ call gives none. `declared_skips` reads the declarations back as
347
+ `{code => metadata}`, and `skip_declarations` as `{code => {message:, metadata:}}`:
348
+
349
+ <!-- doctest -->
350
+ ```ruby
351
+ class SyncField < Actionable::Action
352
+ skips :not_ready, 'the field is not created yet', retry: true
353
+ skips :already_synced, retry: false
354
+
355
+ step :sync
356
+ def sync = skip!(:not_ready)
357
+ end
358
+
359
+ result = SyncField.run
360
+ result.skipped? # => true
361
+ result.code # => :not_ready
362
+ result.message # => "the field is not created yet"
363
+ result.metadata # => {retry: true}
364
+ SyncField.declared_skips # => {not_ready: {retry: true}, already_synced: {retry: false}}
365
+ ```
366
+
367
+ Declarations are inherited, and a subclass can add more. A skip whose code isn't
368
+ declared has `metadata == {}`, and `Skipped#to_h` includes it.
369
+
370
+ **Strict once declared.** An action that declares at least one skip code may only
371
+ skip with codes it declares (`allows_skip_code?(code)` answers whether it may).
372
+ Any other `skip` / `skip!` raises `Actionable::DefinitionError` from the call
373
+ itself. The gem emits no skip codes of its own, so nothing is exempt, and that
374
+ includes `skip`'s implicit default `:skipped`: a declaring action that calls a
375
+ bare `skip!` declares `:skipped` too.
376
+
377
+ <!-- doctest -->
378
+ ```ruby
379
+ class Archive < Actionable::Action
380
+ skips :already_archived
381
+ step :go
382
+ def go = skip!
383
+ end
384
+
385
+ begin
386
+ Archive.run
387
+ rescue Actionable::DefinitionError => e
388
+ e.message.include?('skips with :skipped, which it does not declare') # => true
389
+ end
390
+ ```
391
+
392
+ An action that declares no skips skips with any code, as before. Declared
393
+ failures and declared skips are independent, so declaring one never makes the
394
+ other strict.
395
+
396
+ ## Typed outcomes
397
+
398
+ `typed_outcomes` gives a skip and a failure an output contract of their own,
399
+ the way `output` gives one to a success (decision D23). It is opt-in and
400
+ inherited, so a host base class can turn it on for every action beneath it;
401
+ `typed_outcomes?` reads it, and `typed_outcomes enabled: false` turns it off
402
+ again for a subclass.
403
+
404
+ A block on `skips` or `fails` declares the output a result with that code
405
+ carries, in the same FieldStruct DSL as `output`. `skip_output_schema(code)` and
406
+ `failure_output_schema(code)` read it back, and `describe` shows its fields.
407
+ Under typed outcomes, a failure takes its output from the `output:` keyword of
408
+ `fail` / `fail!` (and of `fail_with` / `fail_with!`); every other keyword is
409
+ still an error:
410
+
411
+ <!-- doctest -->
412
+ ```ruby
413
+ class ActivateKey < Actionable::Action
414
+ typed_outcomes
415
+ input { required :id, :integer }
416
+ output { required :id, :integer }
417
+
418
+ skips(:already_active) { required :id, :integer }
419
+ fails(:invalid) { optional :field, :string }
420
+
421
+ step :activate
422
+
423
+ def activate
424
+ return skip!(:already_active, nil, id: input.id) if input.id == 1
425
+ return fail!(:invalid, 'bad id', output: {field: 'id'}, id: 'is negative') if input.id.negative?
426
+
427
+ @id = input.id
428
+ end
429
+ end
430
+
431
+ skipped = ActivateKey.run(id: 1)
432
+ skipped.class # => ActivateKey::Skipped
433
+ skipped.id # => 1
434
+ failed = ActivateKey.run(id: -1)
435
+ failed.class # => ActivateKey::Failure
436
+ failed.output.field # => "id"
437
+ failed.errors[:id] # => ["is negative"]
438
+ ActivateKey.run(id: 2).class # => ActivateKey::Success
439
+ ```
440
+
441
+ The rules:
442
+
443
+ - **A code with a schema** is built from the keywords it was recorded with,
444
+ never from instance variables, and validated like a success: output that
445
+ breaks the schema ends the run as `invalid_output`. This holds whether or not
446
+ `typed_outcomes` is on, since the block itself is the opt-in for that code.
447
+ So the block also ends instance-variable capture for that code: after
448
+ `@note = 'x'; skip!(:dup)`, a `skips(:dup) { required :note, :string }`
449
+ ends the run as `invalid_output`, because `note:` was never passed.
450
+ - **A code without one** carries an empty output under typed outcomes. Without
451
+ them it keeps the old best-effort capture into the success schema. The
452
+ failures the gem builds (`invalid_input`, `invalid_output`) keep their
453
+ output either way, because the invalid struct is what explains them.
454
+ - **Output with nowhere to go is refused.** Under typed outcomes, output keywords
455
+ on a code without a schema raise `Actionable::DefinitionError`, naming the
456
+ code and asking for a `fails :code do … end` (or `skips`) schema. `output:`
457
+ must be a Hash or a FieldStruct; anything else raises `ArgumentError`.
458
+ - **Result classes.** `.run` returns the action's own `Success`, `Failure` or
459
+ `Skipped`, defined inside it. Each is a subclass of the gem class, or of the
460
+ parent action's own for a subclass, so `result.is_a?(Actionable::Failure)`
461
+ still holds. An action that already has its own constant by one of those names
462
+ is refused. A host's own result subclass is returned as it is, which the
463
+ generated RBS does not describe: it still types `.run` as the action's own
464
+ classes, a known gap.
465
+ - **Nested failures.** A child's failure that crosses becomes the parent's own
466
+ `Failure`. Its output is judged by the schema the *parent* declares for the
467
+ code, and the parent must still declare the code. A parent without typed
468
+ outcomes gets a plain `Actionable::Failure`, never the typed child's class.
469
+ The gem's own `invalid_input` / `invalid_output` cross with their output and
470
+ are not judged by a parent schema.
471
+ - **Stubs.** The RSpec stub helpers return the action's own result classes too,
472
+ with the output shaped as a real run would shape it: built through the code's
473
+ schema (an `ArgumentError` when it breaks the schema, since a real run would
474
+ end as `invalid_output` instead), and empty for a code without one (output
475
+ given for such a code raises `ArgumentError`).
476
+ - **RBS.** `Actionable::RBS.generate` types `.run` as
477
+ `(Success | Skipped | Failure)`, with each `code` and `output` narrowed to
478
+ what that outcome can carry (see [RBS for your actions](#rbs-for-your-actions)).
479
+ The readers a result delegates to its output are declared on `Skipped` and
480
+ `Failure` too.
481
+
482
+ Two things change for a spec when an action turns typed outcomes on. Results
483
+ compare by class, so `ActivateKey.run(id: 2) == Actionable::Success.new(...)` is
484
+ false even with the same output; compare with `ActivateKey::Success`, or use
485
+ `be_success` and the output. And the `allow_actionable_*` / `stub_actionable_*`
486
+ helpers return a new object of the action's own class, not one you built with
487
+ `actionable_failure_result`, so assert on what the helper returns.
488
+
489
+ ## Output
490
+
491
+ `output do … end` declares a typed schema. Matching ivars are captured at the
492
+ end of the run; `succeed`'s keyword arguments overlay them (kwargs win):
493
+
494
+ <!-- doctest -->
495
+ ```ruby
496
+ class Calc < Actionable::Action
497
+ output { optional :total, :integer }
498
+ step :compute
499
+ step :finish
500
+ def compute = @total = 1
501
+ def finish = succeed(total: 99) # overrides the captured ivar
502
+ end
503
+
504
+ Calc.run.output.total # => 99
505
+ ```
506
+
507
+ Only a **strict success** is validated. If a success can't satisfy its declared
508
+ output, the run becomes a `Failure(:invalid_output)` carrying the errors; a
509
+ failed or skipped run captures output best-effort and is never validated.
510
+
511
+ <!-- doctest -->
512
+ ```ruby
513
+ class NeedsTotal < Actionable::Action
514
+ output { required :total, :integer }
515
+ step :noop
516
+ def noop = nil # never sets @total
517
+ end
518
+
519
+ result = NeedsTotal.run
520
+ result.failure? # => true
521
+ result.code # => :invalid_output
522
+ result.errors[:total] # => ["is required"]
523
+ ```
524
+
525
+ `errors` is FieldStruct's own collection, so alongside the per-field
526
+ `errors[:field]` / `errors.to_h` it answers `full_messages` — each message
527
+ rendered as a complete sentence with the humanized field name prepended
528
+ (`:base` messages pass through unprefixed):
529
+
530
+ <!-- doctest -->
531
+ ```ruby
532
+ result = Actionable::Failure.new(code: :invalid)
533
+ result.errors.add(:first_name, 'is required')
534
+
535
+ result.errors.full_messages # => ["First name is required"]
536
+ ```
537
+
538
+ Declared output fields delegate off the result (`result.total` →
539
+ `result.output.total`), except names that collide with the result's own
540
+ attributes (`code`/`message`/`output`/`history`/`errors`).
541
+
542
+ ## Input
543
+
544
+ `input do … end` coerces `.run`'s keyword arguments into a typed struct and
545
+ exposes it as `input`:
546
+
547
+ <!-- doctest -->
548
+ ```ruby
549
+ class Double < Actionable::Action
550
+ input { required :n, :integer }
551
+ output { required :doubled, :integer }
552
+ step :go
553
+ def go = @doubled = input.n * 2
554
+ end
555
+
556
+ Double.run(n: '21').output.doubled # => 42
557
+ Double.run.code # => :invalid_input
558
+ ```
559
+
560
+ A missing or uncoercible required input yields `Failure(:invalid_input)` without
561
+ running any steps. `input` with no block reads the declared schema, like `output`. An already-built input instance can be passed instead of
562
+ keyword arguments. With no `input` block, `.run`'s arguments go to the
563
+ constructor (the free-form path).
564
+
565
+ ### Discriminated input
566
+
567
+ `input_for(discriminator) { … }` picks the input schema at run time from a
568
+ discriminator value — typed input for a polymorphic payload. Branches use the
569
+ same matching as `case_step` (`==`, Array membership, `Regexp`); an unmatched
570
+ value with no `default` short-circuits to `Failure(:invalid_input)`.
571
+
572
+ <!-- doctest -->
573
+ ```ruby
574
+ SaleShape = Class.new(FieldStruct::Base) { required :amount, :integer }
575
+ RefundShape = Class.new(FieldStruct::Base) { required :original_id, :integer }
576
+
577
+ class Ingest < Actionable::Action
578
+ input_for :event_type do
579
+ on 'sale', SaleShape
580
+ on 'refund', RefundShape
581
+ end
582
+ output { optional :amount, :integer }
583
+ step :capture
584
+ def capture
585
+ @amount = input.amount if input.respond_to?(:amount)
586
+ end
587
+ end
588
+
589
+ Ingest.run(event_type: 'sale', amount: '5').output.amount # => 5
590
+ Ingest.run(event_type: 'wat').code # => :invalid_input
591
+ ```
592
+
593
+ ## Batch runs
594
+
595
+ `Action.run_each(enumerable) { |item| run_args }` runs the action once per item
596
+ and collects the per-item results into a `BatchResult` (Enumerable). Each item
597
+ runs independently, so one failure never affects another. The block maps an item
598
+ to `.run` arguments (a Hash → kwargs, an Array → positional, anything else → a
599
+ single argument); omit it to pass each item directly.
600
+
601
+ <!-- doctest -->
602
+ ```ruby
603
+ class Doubler < Actionable::Action
604
+ input { required :n, :integer }
605
+ output { required :doubled, :integer }
606
+ step :go
607
+ def go = @doubled = input.n * 2
608
+ end
609
+
610
+ batch = Doubler.run_each([1, 'x', 3]) { |n| {n: n} } # 'x' -> :invalid_input
611
+ batch.size # => 3
612
+ batch.all_ok? # => false
613
+ batch.any_failure? # => true
614
+ batch.partial? # => true
615
+ batch.successes.map { |r| r.output.doubled } # => [2, 6]
616
+ ```
617
+
618
+ `BatchResult` answers `all_ok?` / `any_failure?` / `partial?`, exposes
619
+ `successes` / `failures` / `skips`, and `to_api_h` renders the whole batch as an
620
+ array of indexed API elements.
621
+
622
+ ## Lifecycle hooks
623
+
624
+ ```ruby
625
+ class Flow < Actionable::Action
626
+ step :work
627
+ on_success :notify # runs only on a success
628
+ on_failure :rollback # runs only on a failure
629
+ on_skip :record # runs only on a skip
630
+ always :audit # runs regardless, after the outcome hook
631
+ end
632
+ ```
633
+
634
+ Dispatch is by the post-main outcome and does not re-dispatch. Hooks may use the
635
+ control-flow verbs. A bang verb in a hook (`fail!`, `skip!`, `succeed!`) records
636
+ its result *and* stops the remaining hooks of its phase, so
637
+ `fail!(:verification_failed)` in `on_success` makes the run a `Failure` (and
638
+ rolls back a `transactional` action); `halt!` stops them and keeps the current
639
+ result. A nested action used as a hook that fails counts as a `fail!`.
640
+
641
+ The outcome hooks and the `always` hooks are separate phases, so `always` runs
642
+ even after an outcome hook halts, and sees the adopted result:
643
+
644
+ | A halt in… | Still runs | Final result |
645
+ |---|---|---|
646
+ | an outcome hook (`on_success` / `on_failure` / `on_skip`) | every `always` hook (not the rest of that outcome category) | the halting hook's |
647
+ | an `always` hook | nothing (the rest of the `always` hooks are skipped) | the halting hook's |
648
+
649
+ `always` hooks run inside the `transactional` transaction like the other hooks,
650
+ and appear in the history when measuring. Output is re-captured and
651
+ re-validated after each hook, and `succeed`/`skip` kwargs keep winning over
652
+ ivars on every refresh.
653
+
654
+ For side effects that must wait until the database work really committed (an
655
+ email, a webhook, an enqueue), the Rails adapter adds an `after_commit` hook
656
+ kind — see [Rails adapter](#rails-adapter).
657
+
658
+ ## Nested actions
659
+
660
+ ```ruby
661
+ step Notify, input: %i[invoice], expose: %i[receipt]
662
+ ```
663
+
664
+ `:input` names parent ivars to pass to the child's `.run`; `:expose` limits which
665
+ child outputs are absorbed back as parent ivars (default: all). Only fields the
666
+ child actually set (an ivar it assigned or a `succeed`/`skip` kwarg) are
667
+ absorbed, so a field it left nil or at its default never overwrites the
668
+ parent's value. A child failure
669
+ fails the parent and halts; a child skip continues the parent like a success.
670
+
671
+ The child's failure crosses intact: its code, message, every error (whatever the
672
+ field is called), its `metadata`, and its result class. It doesn't go through
673
+ the parent's `fail`, so an override of `fail` in a host base class doesn't see
674
+ it. The parent's own declaration for that code fills in metadata keys the child
675
+ didn't set. A parent that declares failures must declare the codes its children
676
+ can fail with, since they become its own.
677
+
678
+ A child skip never becomes the parent's outcome (decision D17), so a parent that
679
+ declares skips does not declare its children's skip codes. The child's skip and
680
+ its metadata stay on the child's result.
681
+
682
+ ## Case steps
683
+
684
+ <!-- doctest -->
685
+ ```ruby
686
+ class Branch < Actionable::Action
687
+ input { required :kind, :string }
688
+ output { optional :picked, :string }
689
+ def kind = input.kind
690
+
691
+ case_step :kind do
692
+ on 'a', :pick_a
693
+ on %w[b c], :pick_bc # Array membership
694
+ on(/\Az/, :pick_z) # Regexp
695
+ default :pick_default
696
+ end
697
+
698
+ def pick_a = @picked = 'a'
699
+ def pick_bc = @picked = 'bc'
700
+ def pick_z = @picked = 'z'
701
+ def pick_default = @picked = 'default'
702
+ end
703
+
704
+ Branch.run(kind: 'b').output.picked # => "bc"
705
+ Branch.run(kind: 'zed').output.picked # => "z"
706
+ Branch.run(kind: 'x').output.picked # => "default"
707
+ ```
708
+
709
+ A branch target is any step target (method or nested action). When nothing
710
+ matches and there's no `default`, the case step is a no-op.
711
+
712
+ A branch (or the `default`) takes `if:` / `unless:` like any step. The branch
713
+ is picked by value alone; its guard then decides whether the target runs. A
714
+ matched branch whose guard opts out does nothing: it does not fall through to
715
+ the next branch or the `default`.
716
+
717
+ <!-- doctest -->
718
+ ```ruby
719
+ class Notify < Actionable::Action
720
+ input { required :kind, :string }
721
+ output { optional :sent, :string }
722
+ def kind = input.kind
723
+ def sms_enabled? = false
724
+
725
+ case_step :kind do
726
+ on 'sms', :send_sms, if: :sms_enabled?
727
+ default :send_email
728
+ end
729
+
730
+ def send_sms = @sent = 'sms'
731
+ def send_email = @sent = 'email'
732
+ end
733
+
734
+ Notify.run(kind: 'sms').output.sent # => nil (guarded out, no fall-through)
735
+ Notify.run(kind: 'push').output.sent # => "email"
736
+ ```
737
+
738
+ ## History & measurement
739
+
740
+ <!-- doctest -->
741
+ ```ruby
742
+ class Timed < Actionable::Action
743
+ measure :all
744
+ step :work
745
+ def work = nil
746
+ end
747
+
748
+ history = Timed.run.history
749
+ history.steps.map(&:name) # => [:work]
750
+ history.steps.first.section # => :main
751
+ ```
752
+
753
+ `measure :none` (the default) records nothing. `measure :sampled, rate: 0.1`
754
+ records a fraction of runs (rate between 0 and 1) for low-overhead production
755
+ observability — when a run is sampled in, it measures fully and cascades into its
756
+ nested actions. A measuring run cascades into the nested actions it invokes.
757
+ `History#took` sums step durations; `History#to_json` serializes via Oj.
758
+ Measurement state is fiber-local: a nested action run inside another Fiber
759
+ (including `Enumerator#next`) is not recorded in the parent's history.
760
+
761
+ <!-- doctest -->
762
+ ```ruby
763
+ class Sampled < Actionable::Action
764
+ measure :sampled, rate: 1.0 # always, for a deterministic example
765
+ step :work
766
+ def work = nil
767
+ end
768
+
769
+ Sampled.run.history.steps.map(&:name) # => [:work]
770
+ ```
771
+
772
+ ## Registry
773
+
774
+ Named action subclasses register themselves, keyed by class name:
775
+
776
+ <!-- doctest -->
777
+ ```ruby
778
+ class Registered < Actionable::Action
779
+ end
780
+
781
+ Actionable.registry[Registered.name].equal?(Registered) # => true
782
+ ```
783
+
784
+ `Actionable.registry` is read-only for callers: `[]`, `each`, `keys`, `values`,
785
+ `size`, `empty?`.
786
+
787
+ ## Introspection — `Action.describe`
788
+
789
+ A structured, at-a-glance summary of an action (name, input/output field
790
+ metadata, ordered steps with type, declared failures and skips, hooks, measure
791
+ mode, transaction config) so humans and agents can understand it without reading
792
+ source. `describe[:failures]` and `describe[:skips]` each map a declared code to
793
+ `{message:, metadata:}`:
794
+
795
+ <!-- doctest -->
796
+ ```ruby
797
+ class Probe < Actionable::Action
798
+ output { required :count, :integer }
799
+ step :compute
800
+ def compute = @count = 1
801
+ end
802
+
803
+ Probe.describe[:steps].first[:type] # => :method
804
+ Probe.describe.key?(:output) # => true
805
+ Probe.describe[:measure] # => :none
806
+ ```
807
+
808
+ `Action.describe_text` renders the same information as a human/agent-readable
809
+ multi-line summary instead of a Hash. The input/output field lines reuse
810
+ FieldStruct's own `Metadata#describe` (each field's type, required-ness, and the
811
+ options its type accepts). Declared failures, declared skips and hook sections
812
+ are listed only when there are any:
813
+
814
+ ```
815
+ Probe (measure: none)
816
+ Input: (free-form)
817
+ Output:
818
+ count (Integer, required) — accepts in (Array | Range)
819
+ Steps:
820
+ - compute (method)
821
+ ```
822
+
823
+ With `fails :refused, 'the card was refused', kind: :rejected` declared, a
824
+ `Failures:` section follows the steps:
825
+
826
+ ```
827
+ Failures:
828
+ - refused (kind: :rejected): the card was refused
829
+ ```
830
+
831
+ With `skips :not_ready, 'the field is not created yet', retry: true` declared, a
832
+ `Skips:` section follows the failures:
833
+
834
+ ```
835
+ Skips:
836
+ - not_ready (retry: true): the field is not created yet
837
+ ```
838
+
839
+ ## Serializing a result
840
+
841
+ `Result#to_h` is a plain-Hash view (`code`, `message`, `output`/`history`
842
+ rendered to primitives, `errors` as a Hash). `Result#to_api_h(index:)` is a
843
+ compact element for an HTTP/batch response — position, `status`
844
+ (`:success`/`:failure`/`:skipped`), the `id` read off the typed output, and
845
+ `errors` — so a controller doesn't hand-map the shape:
846
+
847
+ <!-- doctest -->
848
+ ```ruby
849
+ class MakeWidget < Actionable::Action
850
+ output { required :id, :integer }
851
+ step :build
852
+ def build = @id = 99
853
+ end
854
+
855
+ result = MakeWidget.run
856
+ result.status # => :success
857
+ result.to_h[:output] # => {:id=>99}
858
+ result.to_api_h(index: 0) # => {:index=>0, :status=>:success, :id=>99, :errors=>{}}
859
+ ```
860
+
861
+ ## Guardrails (`DefinitionError`)
862
+
863
+ Misconfigured actions fail loudly with `Actionable::DefinitionError` instead of a
864
+ cryptic `NoMethodError`:
865
+
866
+ - **Reserved output field names (at declaration):** an `output` field that
867
+ shadows a result attribute (`code` / `message` / `output` / `history` /
868
+ `errors` / `metadata`) or a reserved instance variable (`result` / `input`) raises
869
+ immediately. Pick another name. (Input fields aren't affected — they're read
870
+ via `input.x`.)
871
+ - **Undeclared failure codes (at the `fail` call):** once an action declares a
872
+ failure with `fails`, failing with a code it doesn't declare raises. See
873
+ [Declared failures](#declared-failures).
874
+ - **Undeclared skip codes (at the `skip` call):** once an action declares a skip
875
+ with `skips`, skipping with a code it doesn't declare raises, the default
876
+ `:skipped` included. See [Declared skips](#declared-skips).
877
+ - **Missing step methods (at run start):** every method a step needs (method
878
+ steps, case value sources and method branch targets, hook steps) must be
879
+ implemented, or the run raises a `DefinitionError` naming the action and the
880
+ missing method.
881
+ - **A plain verb with code after it (at first run):** a `fail` / `fail_with` /
882
+ `skip` / `succeed` that isn't its step or hook method's last expression raises a
883
+ `DefinitionError` naming the action, method, `file:line` and verb. See
884
+ [Where a plain verb may go](#where-a-plain-verb-may-go).
885
+ - **Sentence codes (at the `fail` / `skip` call):** a code that isn't an
886
+ identifier raises `ArgumentError`. See [Control flow](#control-flow).
887
+
888
+ ## Rails adapter
889
+
890
+ `require 'actionable/rails'` (never loaded by the core). CI runs it on
891
+ ActiveRecord 7.1 and 8.x; only `after_commit` needs 7.2:
892
+
893
+ - `transactional model: :invoice` (or a class) wraps the run in
894
+ `model.transaction`. A success commits; a recorded failure or a raised
895
+ exception rolls back; a skip commits. Inside a transaction that is already
896
+ open, the action takes its own savepoint (`requires_new: true`), so its failed
897
+ writes roll back without the outer transaction committing them; pass
898
+ `requires_new: false` to join the outer one instead. Lifecycle hooks run inside the
899
+ transaction, so a hook's writes share its fate and its external side effects
900
+ fire before the commit.
901
+ - `after_commit :method` (or a nested action: `after_commit Notify, input: %i[order]`,
902
+ with `:if` / `:unless` guards like any hook) runs a side effect only after the
903
+ database work really committed. Needs ActiveRecord >= 7.2 (it is built on
904
+ `ActiveRecord.after_all_transactions_commit`); declaring it on an older
905
+ ActiveRecord, or with ActiveRecord not loaded, raises a `DefinitionError`.
906
+ Existing hooks are unchanged. Inherited like the other hooks. `describe`
907
+ lists them under `hooks[:after_commit]`, and `describe_text` adds an
908
+ `after_commit:` line when there are any.
909
+
910
+ | Scenario | Runs? | When |
911
+ |---|---|---|
912
+ | `transactional` action succeeds at the top level | yes | right after its transaction commits, before `.run` returns |
913
+ | succeeds inside an outer `Model.transaction` | yes | after the **outermost** transaction commits, not at the action's savepoint |
914
+ | succeeds, then an enclosing transaction rolls back | no | — |
915
+ | the run ends in a `Failure` | no | its writes rolled back, so nothing committed |
916
+ | the run ends in a `Skipped` | no | a skip's writes still commit, but there was nothing to do, so no side effect |
917
+ | non-transactional action, no transaction open | yes | immediately after the run, before `.run` returns |
918
+ | nested actions, each with `after_commit` | each once, child first | after the outermost commit; a child's hook is dropped if a transactional parent then fails |
919
+
920
+ The run is already over when the hook fires: it can't change the result (a
921
+ halting verb only stops the remaining `after_commit` hooks) and it is not
922
+ recorded in the history. An exception it raises propagates (decision D4) from
923
+ wherever the commit happened — the outer `transaction` call, or `.run` when no
924
+ transaction was open — after the writes are committed.
925
+ - `Actionable::ProxyValidator` runs ActiveModel rules against a delegate object;
926
+ `#formatted_errors` returns a hash ready to splat into `fail`.
927
+
928
+ ## RSpec integration
929
+
930
+ `require 'actionable/rspec'`:
931
+
932
+ - `perform_actionable(*args, **kwargs)` with `.and_succeed(message)`,
933
+ `.and_fail(code, message)`, `.and_skip(code, message)`,
934
+ `.and_raise(error_class, message)`, and an optional block yielding
935
+ `(result, exception)`.
936
+ - `allow_actionable_success` / `stub_actionable_success` (and `_failure` /
937
+ `_skip` variants) build real result objects so `.run` returns them without
938
+ executing the action. `actionable_failure_result(code:, message:, output:,
939
+ errors:, metadata:)` and `actionable_skip_result(code:, message:, output:,
940
+ metadata:)` build a failure or a skip without stubbing anything.
941
+
942
+ On an action that declares failures, the RSpec support holds to the
943
+ declarations. A stubbed failure carries the declared metadata for its code (and
944
+ the declared message when you give none), and a stub or `and_fail` with a code
945
+ the action doesn't declare raises `ArgumentError`, since it would test a
946
+ failure the action can never return. The exemptions match the runtime: actions
947
+ that declare none, and `GEM_FAILURE_CODES`.
948
+
949
+ Skips work the same way on an action that declares skips. A stubbed skip
950
+ carries the declared metadata and message for its code, and a stub or
951
+ `and_skip` with an undeclared code raises `ArgumentError`. As at runtime, the
952
+ default `:skipped` is not exempt, so `allow_actionable_skip(action)` with no
953
+ `code:` raises unless the action declares `:skipped`. A bare `and_skip` names no
954
+ code and is always accepted.
955
+
956
+ ## Outcome coverage
957
+
958
+ `require 'actionable/rspec/coverage'` (decision D22) records every real `.run`
959
+ of a named action as `[class name, outcome, code]` and, at the end of the
960
+ suite, reports each action's outcomes that no spec reached. It is off unless
961
+ required, and `actionable/rspec` does not load it.
962
+
963
+ An action's expected outcomes come from its declarations at run time, as
964
+ outcome keys. `"success"` is always expected, plus `"failure:<code>"` for each
965
+ `declared_failures` code and `"skipped:<code>"` for each `declared_skips` code.
966
+ `Coverage.expected_outcomes(SomeAction)` lists them.
967
+
968
+ A run counts only when the action really ran. Nested `Steps::Action` runs and
969
+ each `run_each` item count. A stub of `.run` replaces the recording, so a
970
+ stubbed result never counts as tested; the gem's stub helpers note their runs
971
+ in `Coverage.stubbed`, which the report shows as "Stubbed only".
972
+
973
+ ```ruby
974
+ # spec/support/actionable_coverage.rb
975
+ require 'actionable/rspec/coverage'
976
+
977
+ Actionable::Coverage.loader = -> { Rails.application.eager_load! }
978
+ Actionable::Coverage.baseline = 'spec/actionable_coverage_baseline.yml'
979
+ ```
980
+
981
+ Settings, all on `Actionable::Coverage`:
982
+
983
+ | Setting | Default | Meaning |
984
+ |---|---|---|
985
+ | `root` | `Actionable::Action` | Report its named descendants that have steps. Anonymous classes and actions with no steps (abstract bases) are left out, and so is a class whose name no longer resolves to it. |
986
+ | `include_prefixes` / `exclude_prefixes` | `[]` | Name filters. A String matches as a prefix, a Regexp by `match?`. |
987
+ | `loader` | `nil` | Called before the actions are listed. A class that was never loaded can't be reported, so a Rails host eager-loads here. |
988
+ | `gem_codes` | `[]` | Gem codes to expect too. `:invalid_input` counts for an action whose input has a required field, `:invalid_output` for one whose output does. |
989
+ | `only_observed` | `false` | Report only actions with at least one real run, so a targeted run doesn't flag every action it never touched. |
990
+ | `baseline` | `nil` | Path to a committed JSON or YAML baseline of known gaps (below). |
991
+ | `new_gap_hint` | `DEFAULT_NEW_GAP_HINT` | The advice after "New untested outcomes." in the mismatch message. The default, `'Write a spec that reaches each one, or add it to the baseline:'`, offers both fixes; a host whose baseline only shrinks sets its own. |
992
+
993
+ `reset_config!` restores the defaults; `enable!`, `disable!`, `enabled?` and
994
+ `reset!` control the recording, and `observed` holds the real runs.
995
+
996
+ The report (`Actionable::Coverage::Report`) is a table of each action's
997
+ never-reached outcomes followed by the totals:
998
+
999
+ ```text
1000
+ Actionable outcome coverage
1001
+ Action Never reached Stubbed only
1002
+ Billing::Charge failure:refused, failure:not_found failure:refused
1003
+ Billing::Refund success
1004
+ Actions: 2 Expected outcomes: 4 Reached: 1 Missing: 3
1005
+ ```
1006
+
1007
+ `rows`, `totals`, `to_h` and `to_json` give the same data to a machine, and
1008
+ `ACTIONABLE_COVERAGE_JSON=path` writes the JSON next to the text.
1009
+
1010
+ **The baseline.** Missing outcomes alone never fail a suite. With
1011
+ `Coverage.baseline` set to a file of `{"Action::Name" => [outcome keys]}`,
1012
+ the report fails in two cases, and its message lists each entry to change:
1013
+
1014
+ - an untested outcome the baseline doesn't list (a new gap: write the spec,
1015
+ or add the entry);
1016
+ - a baseline entry that is now covered, is no longer declared, or names an
1017
+ action that no longer exists (remove the entry).
1018
+
1019
+ `Report#to_baseline` returns the current gaps in that shape and
1020
+ `#write_baseline(path)` writes them, as YAML for a `.yml`/`.yaml` path and JSON
1021
+ otherwise. Running the report with `ACTIONABLE_COVERAGE_UPDATE_BASELINE=true`
1022
+ rewrites `Coverage.baseline` from the current gaps instead of checking it,
1023
+ which is how a host adopts the baseline and how it accepts a deliberate change.
1024
+
1025
+ **Unsharded suites.** The `after(:suite)` hook prints the report and, when it
1026
+ disagrees with the baseline, raises `Coverage::BaselineMismatch`, which fails
1027
+ the run.
1028
+
1029
+ **Sharded suites.** When `ACTIONABLE_COVERAGE_FILE` names a path, the hook
1030
+ writes that process's runs there (`Coverage.write(path)`) and reports nothing,
1031
+ since one shard can't judge the whole suite. Each rspec process needs its own
1032
+ path. A later step merges the files with the expected outcomes computed in
1033
+ that process: `Coverage.report!(files: 'tmp/actionable_coverage/*.json')`, or
1034
+ `Report.new(files:)` for the report alone. A glob that matches no file raises
1035
+ rather than reporting every outcome missing, and `Coverage.read(path)` reads
1036
+ one file back. From a Rails CI step:
1037
+
1038
+ ```bash
1039
+ RAILS_ENV=test bin/rails runner 'require "./spec/support/actionable_coverage"; Actionable::Coverage.report!(files: "tmp/actionable_coverage/*.json")'
1040
+ ```
1041
+
1042
+ The baseline is only meaningful for a full run. A local or targeted run
1043
+ reports every action it didn't touch, so set `baseline` only in CI and, for
1044
+ local runs, `only_observed = true` (or require the file only when an env var
1045
+ asks for it).
1046
+
1047
+ ## Background jobs
1048
+
1049
+ `require 'actionable/job'` — a framework-agnostic helper that runs an action
1050
+ inside a background job and maps its result to a queue disposition:
1051
+ `Actionable::Job.disposition(result)` returns `:ack` (a success or skip), `:retry`
1052
+ (a transient failure), or `:discard` (a failure whose code is in the
1053
+ non-retryable set, default `%i[invalid_input invalid_output not_found]`).
1054
+
1055
+ Include `Actionable::Job::Mixin` into your Sidekiq worker or ActiveJob job and
1056
+ call `run_actionable` from `perform`. It records `actionable_result`, returns for
1057
+ an ack/discard, and raises `Actionable::Job::RetryableFailure` for a retry so the
1058
+ queue applies its own backoff. A genuine exception inside the action propagates
1059
+ unchanged (the queue retries it):
1060
+
1061
+ ```ruby
1062
+ require 'actionable/job'
1063
+
1064
+ class ProjectEventJob
1065
+ include Sidekiq::Job # or: ActiveJob::Base
1066
+ include Actionable::Job::Mixin
1067
+
1068
+ def perform(event_id)
1069
+ run_actionable(ProjectApiEvent, event_id: event_id)
1070
+ end
1071
+
1072
+ # optional: customize which failure codes are permanent (not retried)
1073
+ def actionable_permanent_codes = %i[invalid_input not_found unprocessable]
1074
+ end
1075
+ ```
1076
+
1077
+ **Retry from declared metadata (opt-in).** Declared metadata is neutral by
1078
+ default (D20/D21): `skips :not_ready, retry: true` alone still acks. An action
1079
+ that calls `honor_retry_metadata` (added to `Actionable::Action` by
1080
+ `require 'actionable/job'`, so load the adapter before the action) lets the
1081
+ `retry:` key of its declared codes decide the disposition:
1082
+
1083
+ | Result | `retry: true` | `retry: false` | no `retry:` key |
1084
+ |---|---|---|---|
1085
+ | `Failure` | `:retry` (even a permanent code) | `:discard` (even a transient code) | default mapping |
1086
+ | `Skipped` | `:retry` | `:ack` | `:ack` |
1087
+
1088
+ `run_actionable` raises `RetryableFailure` for every `:retry`, a retried skip
1089
+ included (`e.result` is the `Skipped`), so the same queue configuration covers
1090
+ both: Sidekiq retries any raised job by default; under ActiveJob declare
1091
+ `retry_on Actionable::Job::RetryableFailure` (otherwise the error goes to the
1092
+ queue backend's own handling, as for any transient failure). CI checks both
1093
+ against the real backends: ActiveJob's test adapter with `retry_on` (a retry is
1094
+ a re-enqueue; once attempts run out the error is raised) and Sidekiq's inline
1095
+ testing mode. The macro is inherited, and a
1096
+ subclass backs out with `honor_retry_metadata(enabled: false)`;
1097
+ `honor_retry_metadata?` reads it. Without a job, ask
1098
+ `Actionable::Job.disposition(result, honor_retry: true)`.
1099
+
1100
+ <!-- doctest -->
1101
+ ```ruby
1102
+ require 'actionable/job'
1103
+
1104
+ class Reindex < Actionable::Action
1105
+ honor_retry_metadata
1106
+ skips :not_ready, retry: true
1107
+
1108
+ step :reindex
1109
+ def reindex = skip!(:not_ready)
1110
+ end
1111
+
1112
+ Reindex.honor_retry_metadata? # => true
1113
+ Actionable::Job.disposition(Reindex.run, honor_retry: true) # => :retry
1114
+ Actionable::Job.disposition(Reindex.run) # => :ack
1115
+ ```
1116
+
1117
+ ## RBS for your actions
1118
+
1119
+ ```ruby
1120
+ Actionable::RBS.generate(CreateInvoice)
1121
+ # => RBS with a typed `.run` (from the input schema) and output accessors /
1122
+ # result delegation (from the output schema), wrapped in module nesting.
1123
+ ```
1124
+
1125
+ For an action with `input` and `output` schemas:
1126
+
1127
+ ```ruby
1128
+ class CreateInvoice < Actionable::Action
1129
+ input do
1130
+ required :amount, :integer
1131
+ optional :tags, :array, of: :string
1132
+ end
1133
+ output { required :total, :integer }
1134
+ step :compute
1135
+
1136
+ def compute
1137
+ @total = input.amount * 2
1138
+ end
1139
+ end
1140
+ ```
1141
+
1142
+ ```
1143
+ class CreateInvoice < ::Actionable::Action
1144
+ def self.run: (amount: ::Integer, ?tags: ::Array[::String?]?) -> Result
1145
+ | (InputSchema input) -> Result
1146
+
1147
+ def input: () -> InputSchema
1148
+
1149
+ @total: ::Integer?
1150
+
1151
+ class InputSchema < ::FieldStruct::Base
1152
+ attr_reader amount: ::Integer
1153
+ def amount=: (::Integer? value) -> ::Integer?
1154
+ attr_reader tags: ::Array[::String?]?
1155
+ def tags=: (::Array[::String?]? value) -> ::Array[::String?]?
1156
+ end
1157
+
1158
+ class Output < ::FieldStruct::Base
1159
+ attr_reader total: ::Integer
1160
+ def total=: (::Integer? value) -> ::Integer?
1161
+ end
1162
+
1163
+ class Result < ::Actionable::Result
1164
+ def output: () -> Output
1165
+ def total: () -> ::Integer
1166
+ end
1167
+ end
1168
+ ```
1169
+
1170
+ Field types come from `FieldStruct::RBS.type_for`, so they match what
1171
+ `FieldStruct::RBS.generate` emits for a named struct: writers take and return
1172
+ `T?`, scalar array elements are nullable, a field's `rbs_type:` override wins,
1173
+ and a `:value` field is `untyped`. Types describe a valid instance: check
1174
+ `valid?` before trusting them on a struct that may not be.
1175
+
1176
+ - **`InputSchema`, like `Output` and `Result`, exists only in RBS.** At runtime
1177
+ the input schema is an anonymous class (`CreateInvoice.input`), and a step's
1178
+ `input` is an instance of it. Generation raises `ArgumentError` when the
1179
+ action declares a fixed `input` schema and Ruby already resolves
1180
+ `CreateInvoice::InputSchema` (a constant by that name on the action, an
1181
+ ancestor or an included module), since the RBS name would then describe
1182
+ something else. An `input_for` (discriminated) action gets no `InputSchema`.
1183
+ - **Output instance variables** (`@total: ::Integer?`) are declared so steps
1184
+ that assign them type-check; each is nullable because it is unset until a
1185
+ step assigns it. The generated RBS owns these declarations: **don't declare
1186
+ them again in your own sig files**, since RBS rejects an instance variable
1187
+ declared in two places.
1188
+
1189
+ An action that declares failures also gets a `failure_code` type alias listing
1190
+ every code it can fail with, the declared ones first, then `GEM_FAILURE_CODES`:
1191
+
1192
+ ```
1193
+ type failure_code = :refused | :not_found | :invalid_input | :invalid_output
1194
+ ```
1195
+
1196
+ An action that declares skips gets a `skip_code` alias the same way, after
1197
+ `failure_code` when both are declared. It lists the declared codes only, since
1198
+ the gem emits no skips of its own:
1199
+
1200
+ ```
1201
+ type skip_code = :not_ready | :already_synced
1202
+ ```
1203
+
1204
+ Without typed outcomes the aliases are type aids only: nothing in the generated
1205
+ `Result` uses them, since `code` is shared by all three outcomes. Under
1206
+ `typed_outcomes` (decision D23) they type each outcome's `code`, and `.run`
1207
+ returns the action's own result classes, each with the output it can carry:
1208
+
1209
+ ```
1210
+ def self.run: (id: ::Integer) -> (Success | Skipped | Failure)
1211
+
1212
+ class AlreadyActiveSkipOutput < ::FieldStruct::Base ... end
1213
+ class InvalidFailureOutput < ::FieldStruct::Base ... end
1214
+
1215
+ class Success < ::Actionable::Success
1216
+ def code: () -> :success
1217
+ def output: () -> Output
1218
+ end
1219
+
1220
+ class Skipped < ::Actionable::Skipped
1221
+ def code: () -> skip_code
1222
+ def output: () -> (AlreadyActiveSkipOutput | ::Hash[::Symbol, untyped])
1223
+ end
1224
+
1225
+ class Failure < ::Actionable::Failure
1226
+ def code: () -> failure_code
1227
+ def output: () -> (InvalidFailureOutput | AlreadyActiveSkipOutput | Output | ::Hash[::Symbol, untyped])
1228
+ end
1229
+ ```
1230
+
1231
+ A failure's output also admits every skip schema and `Output`, since output that
1232
+ breaks one ends the run as `invalid_output` carrying it. For the same reason
1233
+ every reader declared on `Failure` is nullable (`def key: () -> ::KeySummary?`):
1234
+ a broken output can lack a required field. A code with no schema, or no
1235
+ declarations at all, types as a Hash and a plain `::Symbol`.
1236
+
1237
+ Each output class is named after its code, camelized: `:already_active` gives
1238
+ `AlreadyActiveSkipOutput`. A trailing `?` or `!`, which can't appear in a class
1239
+ name, becomes `Predicate` or `Bang`: `fails(:expired?) { … }` gives
1240
+ `ExpiredPredicateFailureOutput` and `skips(:done!) { … }` gives
1241
+ `DoneBangSkipOutput`, so `:expired` and `:expired?` never share a class.
1242
+ `generate` raises `ArgumentError` when two codes of one kind would still land on
1243
+ one name (`:ready` and `:ready_`, or `:expired?` and `:expired_predicate`), or
1244
+ when a code's class name would not be plain ASCII starting with a capital
1245
+ (`:日本`, `:foo_é`): rename one code.
1246
+
1247
+ ### Base actions
1248
+
1249
+ The action is declared with its real superclass, and every named ancestor up to
1250
+ `Actionable::Action` is declared before it, without members, so the RBS resolves
1251
+ (and `rbs validate` passes) even when nobody generates your abstract bases:
1252
+
1253
+ ```ruby
1254
+ class ApplicationAction < Actionable::Action; end
1255
+ module Codes
1256
+ class BaseAction < ApplicationAction; end
1257
+ class DecideMapping < BaseAction
1258
+ output { required :id, :integer }
1259
+ end
1260
+ end
1261
+
1262
+ Actionable::RBS.generate(Codes::DecideMapping)
1263
+ ```
1264
+
1265
+ ```
1266
+ class ApplicationAction < ::Actionable::Action
1267
+ end
1268
+
1269
+ module Codes
1270
+ class BaseAction < ::ApplicationAction
1271
+ end
1272
+ end
1273
+
1274
+ module Codes
1275
+ class DecideMapping < ::Codes::BaseAction
1276
+ def self.run: (*untyped) -> Result
1277
+ ...
1278
+ end
1279
+ end
1280
+ ```
1281
+
1282
+ Write each action's RBS to its own file: the ancestor declarations repeat,
1283
+ byte-identical, in every file that shares a base, and RBS merges them. They also
1284
+ merge with a base's own signature when you generate or write that too, as long
1285
+ as it names the same superclass. A namespace that is a class opens as `class`
1286
+ (without a superclass), not `module`. An anonymous ancestor is skipped: the
1287
+ next named one becomes the superclass.
1288
+
1289
+ A subclass of a typed action subclasses its parent's result classes
1290
+ (`class Success < ::Billing::BaseAction::Success`), so a typed ancestor's
1291
+ declaration also carries its `Success`, `Skipped` and `Failure`, empty.