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
@@ -0,0 +1,611 @@
1
+ # Getting started with Actionable
2
+
3
+ How to use Actionable in a repo — productively, in about ten minutes. Actionable
4
+ builds typed **service objects**: you subclass `Actionable::Action`, declare an
5
+ ordered list of **steps**, implement each as a method, and run it. A run returns
6
+ one typed **result** — a `Success`, `Failure`, or `Skipped` — carrying a `code`,
7
+ `message`, structured `errors`, a typed `output`, and an execution `history`.
8
+
9
+ This is the task-oriented adoption guide. For the exhaustive list of step types,
10
+ options, and macros see [`USAGE.md`](../USAGE.md); for the feature tour see
11
+ [`README.md`](../README.md).
12
+
13
+ ## At a glance — task → API
14
+
15
+ | You want to… | Do this |
16
+ |---|---|
17
+ | Define a service object | `class Settle < Actionable::Action` |
18
+ | Add an ordered step | `step :build` |
19
+ | Run it | `Settle.run(**kwargs)` |
20
+ | Run it over a collection | `Settle.run_each(items)` → `BatchResult` |
21
+ | Type & validate inputs | `input { required :amount, :integer }` |
22
+ | Pick the input shape at runtime | `input_for(:event_type) { on 'sale', SaleShape }` |
23
+ | Type the result payload | `output { required :invoice, Invoice }` |
24
+ | Read a typed output | `result.invoice` / `result.output.invoice` |
25
+ | Record success / failure / skip | `succeed` / `fail(:code)` / `skip(:code)` |
26
+ | Stop the pipeline | `succeed!` / `fail!` / `skip!` / `halt!` |
27
+ | Guard a step | `step :charge, if: :ready?` |
28
+ | Branch on a value | `case_step :status do … end` |
29
+ | Compose actions | `step Other, input: %i[sku], expose: %i[line]` |
30
+ | Run code after the outcome | `on_success` / `on_failure` / `on_skip` / `always` |
31
+ | Override an inherited step or hook | redeclare it: `step :charge, if: :live?` (last wins, same position) |
32
+ | Record timings & history | `measure :all` (or `:sampled, rate:`), then `result.history` |
33
+ | Inspect an action | `Settle.describe` / `Settle.describe_text` |
34
+ | Read errors as sentences | `result.errors.full_messages` |
35
+ | Test an action | `expect(Settle).to perform_actionable(…).and_succeed` |
36
+ | Wrap a run in a DB transaction | `transactional model: :invoice` (Rails adapter) |
37
+ | Send email/webhooks only after commit | `after_commit :send_receipt` (Rails adapter, AR >= 7.2) |
38
+ | Run it from a background job | `include Actionable::Job::Mixin`; add `honor_retry_metadata` to honour `retry:` |
39
+ | Generate RBS for an action | `Actionable::RBS.generate(Settle)` |
40
+
41
+ ## Install
42
+
43
+ ```ruby
44
+ # Gemfile
45
+ gem 'actionable-rb' # then `require 'actionable'`; Bundler.require does it for you
46
+ ```
47
+
48
+ Upgrading an app that already uses Actionable? Follow [UPGRADING.md](../UPGRADING.md).
49
+
50
+ Requires Ruby 3.2+. The core is pure Ruby (only `field_struct`). Two optional
51
+ adapters are separate requires — never loaded by the core:
52
+
53
+ ```ruby
54
+ require 'actionable/rails' # transactions + ProxyValidator (needs Rails / ActiveModel)
55
+ require 'actionable/rspec' # the perform_actionable matcher + stub helpers
56
+ ```
57
+
58
+ ---
59
+
60
+ ## Part 1 — Any Ruby project
61
+
62
+ ### Your first action
63
+
64
+ A step is just a method. Set instance variables that match declared `output`
65
+ fields and they're captured, coerced, and surfaced on the result — and reachable
66
+ directly off it. If no step records an outcome, the run auto-succeeds.
67
+
68
+ <!-- doctest -->
69
+ ```ruby
70
+ class Greet < Actionable::Action
71
+ output { required :greeting, :string }
72
+
73
+ step :build
74
+
75
+ def build = @greeting = 'Hello, World!'
76
+ end
77
+
78
+ result = Greet.run
79
+ result.success? # => true
80
+ result.greeting # => "Hello, World!"
81
+ result.output.greeting # => "Hello, World!"
82
+ ```
83
+
84
+ ### The result model — three honest outcomes
85
+
86
+ Every run returns exactly one result. Beyond success and failure, a **skip** is a
87
+ first-class outcome: the action had nothing to do (a precondition wasn't ready,
88
+ the work was already done). That's neither an error nor real work, so conflating
89
+ it with either loses information. Results can also be built directly (handy in
90
+ tests):
91
+
92
+ <!-- doctest -->
93
+ ```ruby
94
+ ok = Actionable::Success.new(message: 'done')
95
+ ok.success? # => true
96
+ ok.ok? # => true
97
+
98
+ idle = Actionable::Skipped.new(code: :not_ready)
99
+ idle.skipped? # => true
100
+ idle.failure? # => false
101
+ idle.ok? # => true
102
+
103
+ bad = Actionable::Failure.new(code: :nope)
104
+ bad.failure? # => true
105
+ bad.ok? # => false
106
+ ```
107
+
108
+ Predicates: `success?`/`successful?`, `failure?`/`failed?`, `skipped?`, and `ok?`
109
+ (≡ "didn't fail" — true for both a success and a skip). Every result carries
110
+ `code`, `message`, `errors`, `output`, and `history`.
111
+
112
+ ### Typed input
113
+
114
+ `input do … end` coerces `.run`'s keyword arguments into a typed struct and
115
+ exposes it to the steps as `input`. A missing or uncoercible **required** input
116
+ short-circuits to `Failure(:invalid_input)` *without running any steps*.
117
+
118
+ <!-- doctest -->
119
+ ```ruby
120
+ class Double < Actionable::Action
121
+ input { required :n, :integer }
122
+ output { required :doubled, :integer }
123
+
124
+ step :go
125
+
126
+ def go = @doubled = input.n * 2
127
+ end
128
+
129
+ Double.run(n: '21').output.doubled # => 42
130
+ Double.run.code # => :invalid_input
131
+ ```
132
+
133
+ For a polymorphic payload, `input_for` picks the schema at run time from a
134
+ discriminator (typed input without a single fixed shape):
135
+
136
+ <!-- doctest -->
137
+ ```ruby
138
+ Sale = Class.new(FieldStruct::Base) { required :amount, :integer }
139
+ Refund = Class.new(FieldStruct::Base) { required :original_id, :integer }
140
+
141
+ class Handle < Actionable::Action
142
+ input_for :event_type do
143
+ on 'sale', Sale
144
+ on 'refund', Refund
145
+ end
146
+ output { optional :amount, :integer }
147
+ step :go
148
+ def go
149
+ @amount = input.amount if input.respond_to?(:amount)
150
+ end
151
+ end
152
+
153
+ Handle.run(event_type: 'sale', amount: 5).output.amount # => 5
154
+ Handle.run(event_type: 'nope').code # => :invalid_input
155
+ ```
156
+
157
+ ### Typed output
158
+
159
+ `output do … end` declares a FieldStruct schema. Matching ivars are captured at
160
+ the end of the run, coerced through the schema, and exposed as `result.output`
161
+ plus convenience delegation (`result.total` → `result.output.total`). Only a
162
+ **strict success** is validated — a failed or skipped run captures output
163
+ best-effort and is never validated.
164
+
165
+ <!-- doctest -->
166
+ ```ruby
167
+ class Sum < Actionable::Action
168
+ output { required :total, :integer }
169
+
170
+ step :add
171
+
172
+ def add = @total = '42' # coerced through the schema
173
+ end
174
+
175
+ Sum.run.total # => 42
176
+ Sum.run.output.total # => 42
177
+ ```
178
+
179
+ ### Control flow
180
+
181
+ Inside a step, five verbs record the outcome. The plain forms record and keep
182
+ going (**last write wins**); the bang forms also halt the pipeline. A genuine
183
+ `StandardError` is never swallowed — it propagates to the caller.
184
+
185
+ | Verb | Records | Halts? |
186
+ |------|---------|--------|
187
+ | `succeed(message = nil, **output)` | `Success` | no |
188
+ | `fail(code, message = nil, **errors)` | `Failure` | no |
189
+ | `fail_with(source, code: :invalid)` | `Failure` absorbing `source.errors` | no |
190
+ | `skip(code = :skipped, message = nil, **output)` | `Skipped` | no |
191
+ | `succeed!` / `fail!` / `fail_with!` / `skip!` | as above | **yes** |
192
+ | `halt!` | keeps current result | **yes** |
193
+
194
+ `fail`'s keyword arguments populate the result's `errors`; `succeed`'s populate
195
+ its `output`.
196
+
197
+ <!-- doctest -->
198
+ ```ruby
199
+ class Charge < Actionable::Action
200
+ input { required :amount, :integer }
201
+
202
+ step :validate
203
+ step :charge
204
+
205
+ def validate
206
+ fail!(:invalid_amount, 'must be positive') unless input.amount.positive?
207
+ end
208
+
209
+ def charge = succeed("charged #{input.amount}")
210
+ end
211
+
212
+ Charge.run(amount: 10).message # => "charged 10"
213
+ Charge.run(amount: -5).failure? # => true
214
+ Charge.run(amount: -5).code # => :invalid_amount
215
+ ```
216
+
217
+ A skip is just as easy to express, and reads as "nothing to do":
218
+
219
+ <!-- doctest -->
220
+ ```ruby
221
+ class Sync < Actionable::Action
222
+ input { optional :ready, :boolean }
223
+
224
+ step :guard
225
+ step :perform
226
+
227
+ def guard
228
+ skip!(:not_ready, 'nothing to sync yet') unless input.ready
229
+ end
230
+
231
+ def perform = succeed('synced')
232
+ end
233
+
234
+ Sync.run(ready: false).skipped? # => true
235
+ Sync.run(ready: false).ok? # => true
236
+ Sync.run(ready: true).success? # => true
237
+ ```
238
+
239
+ ### Branching — case steps
240
+
241
+ A `case_step` reads a value (from a method or input) and dispatches to a branch
242
+ target, matched by `==`, Array membership, or `Regexp`. A branch target is any
243
+ step target — a method or a nested action.
244
+
245
+ <!-- doctest -->
246
+ ```ruby
247
+ class Triage < Actionable::Action
248
+ input { required :status, :integer }
249
+ output { optional :level, :symbol }
250
+
251
+ def status = input.status
252
+
253
+ case_step :status do
254
+ on (400..499).to_a, :client_error
255
+ on (500..599).to_a, :server_error
256
+ default :ok
257
+ end
258
+
259
+ def client_error = @level = :client
260
+ def server_error = @level = :server
261
+ def ok = @level = :ok
262
+ end
263
+
264
+ Triage.run(status: 404).output.level # => :client
265
+ Triage.run(status: 503).output.level # => :server
266
+ Triage.run(status: 200).output.level # => :ok
267
+ ```
268
+
269
+ ### Composing — nested actions
270
+
271
+ A step can be another action. `:input` names parent ivars to thread into the
272
+ child's `.run`; `:expose` limits which child outputs are absorbed back as parent
273
+ ivars (default: all). A child failure fails the parent (with the child's
274
+ code/message/errors) and halts; a child skip continues the parent.
275
+
276
+ <!-- doctest -->
277
+ ```ruby
278
+ class BuildLine < Actionable::Action
279
+ input { required :sku, :string }
280
+ output { required :line, :string }
281
+ step :build
282
+
283
+ def build = @line = "line:#{input.sku}"
284
+ end
285
+
286
+ class BuildOrder < Actionable::Action
287
+ output { required :line, :string }
288
+ step :set_sku
289
+ step BuildLine, input: %i[sku], expose: %i[line]
290
+
291
+ def set_sku = @sku = 'ABC'
292
+ end
293
+
294
+ BuildOrder.run.output.line # => "line:ABC"
295
+ ```
296
+
297
+ ### Running a batch
298
+
299
+ `run_each` runs the action once per item and collects a `BatchResult` — the
300
+ common "process each row, report per item" endpoint shape. Items run
301
+ independently; the block maps each to `.run` arguments.
302
+
303
+ <!-- doctest -->
304
+ ```ruby
305
+ class Doubler < Actionable::Action
306
+ input { required :n, :integer }
307
+ output { required :doubled, :integer }
308
+ step :go
309
+ def go = @doubled = input.n * 2
310
+ end
311
+
312
+ batch = Doubler.run_each([1, 2, 3]) { |n| {n: n} }
313
+ batch.size # => 3
314
+ batch.all_ok? # => true
315
+ batch.map { |r| r.output.doubled } # => [2, 4, 6]
316
+ ```
317
+
318
+ ### Lifecycle hooks & measurement
319
+
320
+ `on_success`, `on_failure`, `on_skip`, and `always` run after the main steps,
321
+ dispatched by the outcome (then `always`, regardless). Hooks are ordinary methods
322
+ and may use the control-flow verbs; a `fail!` in an outcome hook stops the rest
323
+ of that category, but `always` hooks still run. `measure :all` records a `History` of every
324
+ step (the default `measure :none` records nothing for zero overhead):
325
+
326
+ <!-- doctest -->
327
+ ```ruby
328
+ class Timed < Actionable::Action
329
+ measure :all
330
+ step :work
331
+ def work = nil
332
+ end
333
+
334
+ history = Timed.run.history
335
+ history.steps.map(&:name) # => [:work]
336
+ history.steps.first.section # => :main
337
+ ```
338
+
339
+ ### Introspecting an action
340
+
341
+ `describe` returns a structured Hash (name, input/output field metadata, ordered
342
+ steps, hooks, measure mode, transaction config); `describe_text` renders the same
343
+ information as a human/agent-readable string. Both let a reader understand an
344
+ action without opening its source.
345
+
346
+ <!-- doctest -->
347
+ ```ruby
348
+ class Probe < Actionable::Action
349
+ output { required :count, :integer }
350
+ step :compute
351
+ def compute = @count = 1
352
+ end
353
+
354
+ Probe.describe[:measure] # => :none
355
+ Probe.describe[:steps].first[:type] # => :method
356
+ Probe.describe.key?(:output) # => true
357
+ ```
358
+
359
+ `Probe.describe_text` reuses FieldStruct's own field summaries for the
360
+ input/output lines:
361
+
362
+ ```
363
+ Probe (measure: none)
364
+ Input: (free-form)
365
+ Output:
366
+ count (Integer, required) — accepts in (Array | Range)
367
+ Steps:
368
+ - compute (method)
369
+ ```
370
+
371
+ ### When things go wrong — error → meaning → fix
372
+
373
+ <!-- doctest -->
374
+ ```ruby
375
+ class NeedsTotal < Actionable::Action
376
+ output { required :total, :integer }
377
+ step :noop
378
+ def noop = nil # never sets @total
379
+ end
380
+
381
+ result = NeedsTotal.run
382
+ result.code # => :invalid_output
383
+ result.errors[:total] # => ["is required"]
384
+ ```
385
+
386
+ `errors` is FieldStruct's own collection, so beyond `errors[:field]` you get
387
+ `full_messages` — each message as a complete, field-name-prefixed sentence:
388
+
389
+ <!-- doctest -->
390
+ ```ruby
391
+ failed = Actionable::Failure.new(code: :invalid)
392
+ failed.errors.add(:first_name, 'is required')
393
+
394
+ failed.errors.full_messages # => ["First name is required"]
395
+ ```
396
+
397
+ | You see | It means | Do |
398
+ |---|---|---|
399
+ | `Failure(:invalid_input)` | a `required` input was missing or wouldn't coerce; **no steps ran** | check `.run`'s kwargs against the `input` schema; read `result.errors` |
400
+ | `Failure(:invalid_output)` | a strict success couldn't satisfy the `output` schema | set every `required` output ivar (or pass it via `succeed(field: …)`) |
401
+ | `result.errors[:x] == ["is required"]` | a declared field is missing/blank | set the matching ivar / pass the input |
402
+ | `DefinitionError: … declares step(s) calling :x but does not implement them` | a step / hook / case target method is missing | implement the method, or remove the step |
403
+ | `DefinitionError: output field(s) :code conflict with reserved …` | an output field name shadows a reserved result attribute/ivar | rename the field |
404
+ | a raised `StandardError` out of `.run` | a genuine bug inside a step — **not** control flow | fix it; expected error outcomes use `fail`/`fail!`, not exceptions |
405
+
406
+ ### Common mistakes
407
+
408
+ 1. **Bang vs plain verbs.** `fail` records but the pipeline keeps running (last
409
+ write wins); `fail!` halts immediately. Reach for the bang to stop.
410
+ 2. **Output is captured from ivars *by name*.** Only ivars matching a declared
411
+ `output` field are captured — a typo'd `@totals` is silently dropped. Set
412
+ `@total`.
413
+ 3. **Only a strict success validates output.** A failed or skipped run captures
414
+ output best-effort and never validates it; don't rely on output typing on a
415
+ non-success.
416
+ 4. **An invalid required input never reaches your steps.** It short-circuits to
417
+ `Failure(:invalid_input)` before step one — put "missing argument" handling in
418
+ the `input` schema, not a guard step.
419
+ 5. **Reserved output names.** `code` / `message` / `output` / `history` /
420
+ `errors` / `metadata` / `result` / `input` can't be output fields (they'd shadow the
421
+ result). Rename them.
422
+ 6. **Exceptions aren't failures.** A raised `StandardError` propagates to the
423
+ caller; it does not become a `Failure`. Use `fail`/`fail!` for expected error
424
+ outcomes.
425
+ 7. **`fail` is not `raise` inside an action.** The gem's `fail` verb replaces
426
+ `Kernel#fail` there, so `fail 'boom'` records a `Failure` with code
427
+ `:boom` and the run continues (a sentence code raises `ArgumentError`). Use
428
+ `raise` to signal a bug. And a plain `fail` / `fail_with` / `skip` / `succeed` must be
429
+ the last expression of its step or hook method, or the action raises
430
+ `DefinitionError` on its first run; see
431
+ [USAGE, Control flow](../USAGE.md#where-a-plain-verb-may-go).
432
+ 8. **Redeclaring a step overrides it.** A second `step :x` (in the class or a
433
+ subclass) replaces the first's options and keeps its position, so the last
434
+ declaration wins. See [USAGE, Steps](../USAGE.md#steps).
435
+
436
+ ### Testing your actions
437
+
438
+ `require 'actionable/rspec'` adds a matcher that runs the action and asserts the
439
+ outcome, plus stub helpers that return real result objects without executing it:
440
+
441
+ ```ruby
442
+ require 'actionable/rspec'
443
+
444
+ RSpec.describe Charge do
445
+ it 'succeeds for a positive amount' do
446
+ expect(described_class).to perform_actionable(amount: 10).and_succeed('charged 10')
447
+ end
448
+
449
+ it 'fails for a non-positive amount' do
450
+ expect(described_class).to perform_actionable(amount: -5).and_fail(:invalid_amount)
451
+ end
452
+
453
+ it 'skips when there is nothing to do' do
454
+ expect(Sync).to perform_actionable(ready: false).and_skip(:not_ready)
455
+ end
456
+ end
457
+
458
+ # Make a collaborator's action return a canned result without running it:
459
+ allow_actionable_success(Charge, output: { amount: 10 })
460
+ ```
461
+
462
+ `and_raise(ErrorClass, message)` asserts a propagated exception, and every matcher
463
+ accepts a block yielding `(result, exception)` for custom assertions.
464
+
465
+ To learn which outcomes your specs never reach, `require 'actionable/rspec/coverage'`
466
+ in a spec support file. The end of the suite then lists, per action, each
467
+ declared failure or skip (and success) that no spec produced. USAGE's
468
+ "Outcome coverage" section covers the baseline and sharded suites.
469
+
470
+ ### Agent toolbox
471
+
472
+ If you're an AI assistant working in this repo, reach for the gem's machine
473
+ affordances instead of guessing:
474
+
475
+ - `Klass.describe` / `Klass.describe_text` — *see* an action's inputs, outputs,
476
+ steps, and hooks without reading its source.
477
+ - `Actionable.registry` — enumerate registered actions (`keys`, `values`, `[]`,
478
+ `each`).
479
+ - `Actionable::RBS.generate(Klass)` — emit RBS for an action's typed `.run`,
480
+ `input`, output instance variables and output accessors (Steep / Solargraph).
481
+ - `result.errors.full_messages` — validation errors as complete sentences.
482
+ - `result.to_h` / `result.to_api_h(index:)` — a plain-Hash or HTTP-element view
483
+ of a result, so controllers and batch endpoints don't hand-map the shape.
484
+ - The gem ships [`USAGE.md`](../USAGE.md) (full reference) and a Claude Code skill
485
+ (`skills/actionable/SKILL.md`) — both are in `bundle show actionable`.
486
+
487
+ ---
488
+
489
+ ## Part 2 — Rails
490
+
491
+ The core never loads `active_*`. The Rails adapter is opt-in:
492
+
493
+ ```ruby
494
+ require 'actionable/rails' # e.g. in config/initializers/actionable.rb
495
+ ```
496
+
497
+ ### Where Actionable fits
498
+
499
+ Use an action for a **unit of business work** — settle an invoice, onboard a
500
+ user, sync a record — anywhere you'd otherwise reach for a "service object". The
501
+ typed `input`/`output` give you a validated boundary; the three outcomes let a
502
+ controller branch on success / failure / skip honestly. It is **not** a model and
503
+ **not** a form object; it orchestrates them.
504
+
505
+ ### File placement (Zeitwerk)
506
+
507
+ Put actions in an autoloaded path so Rails loads them by name. A common choice is
508
+ `app/actions/`:
509
+
510
+ ```
511
+ app/actions/
512
+ billing/
513
+ settle_invoice.rb # class Billing::SettleInvoice < Actionable::Action
514
+ charge_card.rb # class Billing::ChargeCard < Actionable::Action
515
+ ```
516
+
517
+ The path mirrors the constant (`app/actions/billing/settle_invoice.rb` ↔
518
+ `Billing::SettleInvoice`); Zeitwerk autoloads them — no `require` in app code.
519
+
520
+ ### Transactions
521
+
522
+ `transactional` wraps the whole run in `model.transaction`. A success commits; a
523
+ **recorded failure or a raised exception rolls back**; a skip commits (there's
524
+ nothing to undo). Called inside a transaction your app already opened, the
525
+ action takes its own savepoint (`requires_new: true`), so a recorded failure
526
+ rolls back the action's writes and leaves the rest of your transaction alone;
527
+ pass `requires_new: false` to join it instead. The lifecycle hooks run **inside** the transaction too: a
528
+ write in `on_failure` rolls back with the rest, and an email or webhook sent
529
+ from `on_success` fires before the commit. Put external side effects in an
530
+ `after_commit` hook instead (below).
531
+
532
+ ```ruby
533
+ class Billing::SettleInvoice < Actionable::Action
534
+ transactional model: :invoice # wraps the run in Invoice.transaction
535
+ # (pass a class for an explicit one)
536
+ input { required :invoice, Invoice }
537
+
538
+ step :debit
539
+ step :credit
540
+
541
+ def debit = input.invoice.debit!
542
+ def credit = input.invoice.credit! # a raise here rolls the debit back
543
+ end
544
+ ```
545
+
546
+ ### Side effects after the commit
547
+
548
+ `after_commit` (ActiveRecord >= 7.2) runs a hook only once the data is really
549
+ committed, and only when the run succeeded:
550
+
551
+ ```ruby
552
+ class Billing::SettleInvoice < Actionable::Action
553
+ transactional model: :invoice
554
+ step :debit
555
+ step :credit
556
+ after_commit :send_receipt # or a nested action: after_commit SendReceipt, input: %i[invoice]
557
+
558
+ def send_receipt = ReceiptMailer.with(invoice: input.invoice).deliver_later
559
+ end
560
+ ```
561
+
562
+ It waits for the **outermost** transaction: called inside a transaction your app
563
+ already opened, the receipt goes out when that one commits, and never if it rolls
564
+ back. With no transaction open it runs right after the run. A failure or a skip
565
+ never runs it. It can't change the result and isn't in the history; an exception
566
+ it raises propagates after the commit.
567
+
568
+ ### Validating with ActiveModel — `ProxyValidator`
569
+
570
+ `ProxyValidator` runs ActiveModel rules against a delegate object;
571
+ `#formatted_errors` returns a hash ready to splat into `fail`:
572
+
573
+ ```ruby
574
+ class InvoiceRules < Actionable::ProxyValidator
575
+ validates :amount, presence: true, numericality: { greater_than: 0 }
576
+ end
577
+
578
+ # inside a step:
579
+ rules = InvoiceRules.new(@invoice)
580
+ fail!(:invalid, **rules.formatted_errors) unless rules.valid?
581
+ ```
582
+
583
+ ### Types for Steep / Solargraph (RBS)
584
+
585
+ The gem ships RBS in `sig/` for the core and for the adapters you call:
586
+ `transactional` / `after_commit` (`actionable/rails`), `Actionable::Job`, and
587
+ the RSpec matchers and stubs (`actionable/rspec`). RBS has no conditional
588
+ loading, so those adapter methods are declared whether or not you require the
589
+ adapter.
590
+
591
+ If your app runs a type checker, generate RBS for your actions' dynamic `.run`
592
+ and output accessors:
593
+
594
+ ```ruby
595
+ # lib/tasks/actionable_rbs.rake
596
+ namespace :actionable do
597
+ task rbs: :environment do
598
+ classes = [Billing::SettleInvoice, Billing::ChargeCard]
599
+ File.write('sig/actions.rbs',
600
+ classes.map { |k| Actionable::RBS.generate(k) }.join("\n"))
601
+ end
602
+ end
603
+ ```
604
+
605
+ ---
606
+
607
+ ## Where to go next
608
+
609
+ - [`USAGE.md`](../USAGE.md) — every step type, option, and macro on one page.
610
+ - [`README.md`](../README.md) — the feature tour.
611
+ - `docs/origin/plan.md` — the design rationale and locked decisions (D1–D23).