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,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Adrian Madrid
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,555 @@
1
+ # Actionable
2
+
3
+ Typed, composable Ruby **service objects**. Subclass `Actionable::Action`,
4
+ declare an ordered list of **steps**, implement each as a method, and run it. A
5
+ run returns a single value object — a `Success`, `Failure`, or `Skipped` — with a
6
+ `code`, `message`, structured `errors`, a typed `output`, and an execution
7
+ `history`.
8
+
9
+ - **Pure-Ruby core** — depends only on [`field_struct`](https://github.com/Paymentbox-com/field_struct). Rails (transactions, validators) is an optional adapter.
10
+ - **Typed in and out** — declared `input`/`output` schemas, coerced and validated through FieldStruct, with full RBS via Sord + YARD.
11
+ - **Three honest outcomes** — success, failure, and *skip* (nothing to do — neither).
12
+
13
+ ## Installation
14
+
15
+ The gem is named **`actionable-rb`** (the `actionable` gem on rubygems.org is
16
+ an unrelated 2014 project). You still `require 'actionable'` and use the
17
+ `Actionable` module; `Bundler.require` loads it from the gem name. Until both
18
+ gems are on rubygems.org, install them from GitHub, pinned to a release tag:
19
+
20
+ ```ruby
21
+ # Gemfile
22
+ gem 'field_struct', git: 'https://github.com/Paymentbox-com/field_struct', tag: 'v0.15.0'
23
+ gem 'actionable-rb', git: 'https://github.com/Paymentbox-com/actionable', tag: 'v1.9.0'
24
+ ```
25
+
26
+ Bundler matches a git source by the gem's name: tags up to `v1.8.2` name it
27
+ `actionable`, so an app moving from those renames its Gemfile line (see
28
+ [UPGRADING.md](UPGRADING.md)).
29
+
30
+ Requires Ruby 3.2+. Upgrading from an earlier release? See [UPGRADING.md](UPGRADING.md).
31
+
32
+ ## Quick start
33
+
34
+ <!-- doctest -->
35
+ ```ruby
36
+ class Greet < Actionable::Action
37
+ output { required :greeting, :string }
38
+
39
+ step :build
40
+
41
+ def build
42
+ @greeting = "Hello, #{name}!"
43
+ end
44
+
45
+ def name = 'World'
46
+ end
47
+
48
+ result = Greet.run
49
+ result.success? # => true
50
+ result.greeting # => "Hello, World!"
51
+ result.output.greeting # => "Hello, World!"
52
+ ```
53
+
54
+ A step is just a method. Set instance variables that match declared `output`
55
+ fields and they're captured, coerced, and surfaced on the result — and reachable
56
+ directly off it (`result.greeting`). If no step records a result, the run
57
+ auto-succeeds.
58
+
59
+ ## Control flow
60
+
61
+ Inside a step, five verbs record the outcome. The bang forms also halt the
62
+ pipeline; the plain forms record and keep going (last write wins). Genuine
63
+ exceptions are never swallowed — they propagate to the caller.
64
+
65
+ | Verb | Records | Halts? |
66
+ |------|---------|--------|
67
+ | `succeed(message = nil, **output)` | `Success` | no |
68
+ | `fail(code, message = nil, **errors)` | `Failure` | no |
69
+ | `fail_with(source, code: :invalid)` | `Failure` absorbing `source.errors` | no |
70
+ | `skip(code = :skipped, message = nil, **output)` | `Skipped` | no |
71
+ | `succeed!` / `fail!` / `fail_with!` / `skip!` | as above | **yes** |
72
+ | `halt!` | — (keeps current) | **yes** |
73
+
74
+ `fail_with` absorbs an arbitrary FieldStruct's validation errors into the failure
75
+ — handy for validating a side struct in a step. `skip` takes output kwargs too,
76
+ so an idempotent hit can return the existing record's data.
77
+
78
+ Inside an action, `fail` is this verb, not Ruby's `Kernel#fail`: it never
79
+ raises, and a sentence code (`fail 'card expired'`) raises `ArgumentError`. Use
80
+ `raise` for exceptions. A plain `fail` / `fail_with` / `skip` / `succeed` must be the last
81
+ expression of its step or hook method (`fail :x if bad?` followed by more code
82
+ is a `fail!` missing its bang): an action that breaks this raises
83
+ `DefinitionError` on its first run, and the opt-in `Actionable/VerbNotLast`
84
+ RuboCop cop flags it in your editor. While upgrading an app that has misplaced
85
+ verbs, `Actionable.verb_position_check = false` turns the first-run check off
86
+ (the cop is then the only guard). See
87
+ [USAGE, Control flow](USAGE.md#where-a-plain-verb-may-go).
88
+
89
+ <!-- doctest -->
90
+ ```ruby
91
+ class Charge < Actionable::Action
92
+ input { required :amount, :integer }
93
+
94
+ step :validate
95
+ step :charge
96
+
97
+ def validate
98
+ fail!(:invalid_amount, 'must be positive') unless input.amount.positive?
99
+ end
100
+
101
+ def charge
102
+ succeed("charged #{input.amount}")
103
+ end
104
+ end
105
+
106
+ ok = Charge.run(amount: 10)
107
+ ok.success? # => true
108
+ ok.message # => "charged 10"
109
+
110
+ bad = Charge.run(amount: -5)
111
+ bad.failure? # => true
112
+ bad.code # => :invalid_amount
113
+ ```
114
+
115
+ ## Three outcomes, not two
116
+
117
+ A **skip** means the action had nothing to do — a precondition wasn't ready, the
118
+ work was already done. That's not a failure (no error, nothing to retry or
119
+ alert) and not a success (no real work). Conflating it with either loses
120
+ information; `Actionable` makes it a first-class outcome.
121
+
122
+ <!-- doctest -->
123
+ ```ruby
124
+ class Sync < Actionable::Action
125
+ input { optional :ready, :boolean }
126
+
127
+ step :guard
128
+ step :perform
129
+
130
+ def guard
131
+ skip!(:not_ready, 'nothing to sync yet') unless input.ready
132
+ end
133
+
134
+ def perform
135
+ succeed('synced')
136
+ end
137
+ end
138
+
139
+ idle = Sync.run(ready: false)
140
+ idle.skipped? # => true
141
+ idle.success? # => false
142
+ idle.failure? # => false
143
+ idle.ok? # => true
144
+ idle.code # => :not_ready
145
+
146
+ Sync.run(ready: true).success? # => true
147
+ ```
148
+
149
+ Predicates: `success?` / `successful?`, `failure?` / `failed?`, `skipped?`, and
150
+ `ok?` (≡ "didn't fail" — true for both a success and a skip).
151
+
152
+ ## Declared failures
153
+
154
+ An action can declare the failure codes it ends with, each with neutral
155
+ metadata. The gem stores the metadata, attaches it to the failure and shows it
156
+ in `describe`, but never interprets it, so your app decides what a key like
157
+ `kind:` means:
158
+
159
+ <!-- doctest -->
160
+ ```ruby
161
+ class Capture < Actionable::Action
162
+ fails :refused, 'the card was refused', kind: :rejected
163
+ fails :expired, kind: :rejected
164
+
165
+ step :capture
166
+ def capture = fail!(:refused)
167
+ end
168
+
169
+ result = Capture.run
170
+ result.code # => :refused
171
+ result.metadata # => {kind: :rejected}
172
+ Capture.declared_failures.keys # => [:refused, :expired]
173
+ ```
174
+
175
+ Once an action declares any code, a `fail` with an undeclared one raises
176
+ `Actionable::DefinitionError` at the call. Actions that declare none, and the
177
+ codes the gem emits itself (`:invalid_input`, `:invalid_output`), are unchecked.
178
+ A nested action's failure reaches its parent with its code, message, errors and
179
+ metadata intact. See [USAGE](USAGE.md#declared-failures).
180
+
181
+ Skips are declared the same way with `skips`, so every way an action can end is
182
+ part of its contract:
183
+
184
+ <!-- doctest -->
185
+ ```ruby
186
+ class Reindex < Actionable::Action
187
+ skips :not_ready, 'the index is still building', retry: true
188
+
189
+ step :reindex
190
+ def reindex = skip!(:not_ready)
191
+ end
192
+
193
+ skipped = Reindex.run
194
+ skipped.code # => :not_ready
195
+ skipped.metadata # => {retry: true}
196
+ Reindex.declared_skips # => {not_ready: {retry: true}}
197
+ ```
198
+
199
+ Metadata is yours to interpret: the gem stores and returns it but never acts
200
+ on it (decisions D20/D21). In particular `Actionable::Job` acks every skip;
201
+ `retry: true` above does not make a job retry unless the action opts in with
202
+ `honor_retry_metadata` (see [Background jobs](#background-jobs-optional)).
203
+ Otherwise read `result.metadata` in your job, or list permanent failure codes
204
+ with `actionable_permanent_codes`.
205
+
206
+ Once an action declares any skip code, a `skip` with an undeclared one raises
207
+ `Actionable::DefinitionError`, and that includes the default `:skipped`. See
208
+ [USAGE](USAGE.md#declared-skips).
209
+
210
+ ## Typed output
211
+
212
+ `output do … end` builds a FieldStruct schema. Matching instance variables are
213
+ captured at the end of the run, coerced through the schema, and exposed as
214
+ `result.output` plus convenience delegation. Only a **strict success** is
215
+ validated — a failed or skipped run captures output best-effort.
216
+
217
+ <!-- doctest -->
218
+ ```ruby
219
+ class Sum < Actionable::Action
220
+ output { required :total, :integer }
221
+
222
+ step :add
223
+
224
+ def add
225
+ @total = '42' # coerced through the schema
226
+ end
227
+ end
228
+
229
+ Sum.run.total # => 42
230
+ Sum.run.output.total # => 42
231
+ ```
232
+
233
+ ## Typed input
234
+
235
+ `input do … end` makes `.run` coerce its keyword arguments into a typed struct,
236
+ validate them, and expose `input` to the steps. A missing or invalid required
237
+ input short-circuits to a `Failure(:invalid_input)` without running any steps.
238
+ Without an `input` block, `.run`'s arguments are forwarded to the constructor.
239
+
240
+ For a polymorphic payload, `input_for` picks the schema at run time from a
241
+ discriminator — typed input without a single fixed shape:
242
+
243
+ ```ruby
244
+ input_for :event_type do
245
+ on 'sale', SaleShape
246
+ on 'refund', RefundShape
247
+ default UnknownShape
248
+ end
249
+ ```
250
+
251
+ ## Running a batch
252
+
253
+ `Action.run_each(enumerable) { |item| run_args }` runs the action once per item
254
+ and collects a `BatchResult` (Enumerable) — the common "process each row, report
255
+ per item" shape. Items run independently.
256
+
257
+ ```ruby
258
+ batch = ImportRow.run_each(rows) { |row| { row: row } }
259
+ batch.all_ok? # => false
260
+ batch.failures.size # => 2
261
+ batch.to_api_h # => [{index: 0, status: :success, id: …, errors: {}}, …]
262
+ ```
263
+
264
+ ## Composition
265
+
266
+ Steps can be other actions or value-based branches.
267
+
268
+ **Nested actions** thread named instance variables in (`:input`) and absorb
269
+ selected outputs back (`:expose`, default all). A nested failure fails the
270
+ parent; a nested skip continues it.
271
+
272
+ <!-- doctest -->
273
+ ```ruby
274
+ class BuildLine < Actionable::Action
275
+ input { required :sku, :string }
276
+ output { required :line, :string }
277
+ step :build
278
+
279
+ def build = @line = "line:#{input.sku}"
280
+ end
281
+
282
+ class BuildOrder < Actionable::Action
283
+ output { required :line, :string }
284
+ step :set_sku
285
+ step BuildLine, input: %i[sku], expose: %i[line]
286
+
287
+ def set_sku = @sku = 'ABC'
288
+ end
289
+
290
+ BuildOrder.run.output.line # => "line:ABC"
291
+ ```
292
+
293
+ **Case steps** branch on a value, matched by `==`, `Regexp`, or Array membership.
294
+
295
+ <!-- doctest -->
296
+ ```ruby
297
+ class Triage < Actionable::Action
298
+ input { required :status, :integer }
299
+ output { optional :level, :symbol }
300
+
301
+ def status = input.status
302
+
303
+ case_step :status do
304
+ on (400..499).to_a, :client_error
305
+ on (500..599).to_a, :server_error
306
+ default :ok
307
+ end
308
+
309
+ def client_error = @level = :client
310
+ def server_error = @level = :server
311
+ def ok = @level = :ok
312
+ end
313
+
314
+ Triage.run(status: 404).output.level # => :client
315
+ Triage.run(status: 503).output.level # => :server
316
+ Triage.run(status: 200).output.level # => :ok
317
+ ```
318
+
319
+ ## Lifecycle hooks
320
+
321
+ `on_success`, `on_failure`, `on_skip`, and `always` run after the main steps,
322
+ dispatched by the outcome (then `always`, regardless). Hooks are ordinary
323
+ methods and may use the control-flow verbs. A bang verb in an outcome hook
324
+ stops the rest of that category and becomes the result, but the `always` hooks
325
+ still run, so cleanup and audit can't be skipped. Redeclaring a step or hook
326
+ (in the same class or a subclass) overrides it in place: the last declaration
327
+ wins. See [USAGE](USAGE.md#lifecycle-hooks) and [Steps](USAGE.md#steps).
328
+
329
+ ```ruby
330
+ class Publish < Actionable::Action
331
+ step :publish
332
+
333
+ on_success :notify
334
+ on_failure :rollback
335
+ on_skip :log_skip
336
+ always :audit
337
+
338
+ # ...
339
+ end
340
+ ```
341
+
342
+ ## History & measurement
343
+
344
+ `measure :all` records a `History` of every step — section, name, timing,
345
+ result code, and nested child history. The default (`measure :none`) records
346
+ nothing for zero overhead. `measure :sampled, rate: 0.1` records a fraction of
347
+ runs for low-overhead production observability. Measurement cascades into nested
348
+ actions.
349
+
350
+ ```ruby
351
+ class Pipeline < Actionable::Action
352
+ measure :all
353
+ step :a
354
+ step :b
355
+ end
356
+
357
+ history = Pipeline.run.history
358
+ history.steps.map(&:name) # => [:a, :b]
359
+ history.took # total seconds
360
+ history.to_json # Oj-serialized
361
+ ```
362
+
363
+ ## Registry & introspection
364
+
365
+ Every named action registers itself for discovery and tooling, and
366
+ `Action.describe` returns a structured summary (input/output, steps, hooks,
367
+ measure) so humans and agents can understand an action without reading source:
368
+
369
+ ```ruby
370
+ Actionable.registry['Greet'] # => Greet
371
+ Greet.describe # => { name: "Greet", steps: [...], output: {...}, ... }
372
+ Greet.describe_text # => "Greet (measure: none)\n Input: ..." (readable form)
373
+ ```
374
+
375
+ Failure results expose FieldStruct's error collection, so beyond
376
+ `result.errors[:field]` you get `result.errors.full_messages` — each message as
377
+ a complete sentence (`"Total is required"`). `result.to_h` is a plain-Hash view,
378
+ and `result.to_api_h(index:)` a compact HTTP/batch element (`index`, `status`,
379
+ `id`, `errors`).
380
+
381
+ ## Guardrails
382
+
383
+ Misconfigured actions fail loudly with `Actionable::DefinitionError` rather than
384
+ a cryptic `NoMethodError`: declaring an `output` field that shadows a result
385
+ attribute or reserved ivar is rejected at declaration time, and a declared step
386
+ whose method isn't implemented is caught at run start (naming the action and
387
+ method).
388
+
389
+ ## Rails adapter (optional)
390
+
391
+ `require 'actionable/rails'` adds the `transactional` macro and
392
+ `Actionable::ProxyValidator`. The core never loads `active_*`.
393
+
394
+ ```ruby
395
+ require 'actionable/rails'
396
+
397
+ class Settle < Actionable::Action
398
+ transactional model: :invoice # wraps the run in Invoice.transaction
399
+
400
+ step :debit
401
+ step :credit
402
+ end
403
+ ```
404
+
405
+ A success commits; a recorded failure **or** a raised exception rolls back; a
406
+ skip commits (nothing to roll back).
407
+
408
+ `after_commit :send_receipt` (ActiveRecord >= 7.2) runs a side effect only
409
+ after the **outermost** transaction commits a successful run — never after a
410
+ rollback, a failure, or a skip; immediately when no transaction is open.
411
+
412
+ `ProxyValidator` applies ActiveModel rules to a delegate object:
413
+
414
+ ```ruby
415
+ class InvoiceRules < Actionable::ProxyValidator
416
+ validates :amount, presence: true, numericality: { greater_than: 0 }
417
+ end
418
+
419
+ # inside a step:
420
+ rules = InvoiceRules.new(@invoice)
421
+ fail(:invalid, **rules.formatted_errors) unless rules.valid?
422
+ ```
423
+
424
+ ## RSpec integration (optional)
425
+
426
+ `require 'actionable/rspec'` adds the `perform_actionable` matcher and stub
427
+ helpers:
428
+
429
+ ```ruby
430
+ expect(CreateInvoice).to perform_actionable(amount: 5).and_succeed
431
+ expect(CreateInvoice).to perform_actionable(bad).and_fail(:invalid_input)
432
+ expect(Sync).to perform_actionable(ready: false).and_skip(:not_ready)
433
+
434
+ allow_actionable_success(CreateInvoice, output: { id: 7 })
435
+ ```
436
+
437
+ ## Outcome coverage (optional)
438
+
439
+ `require 'actionable/rspec/coverage'` records which outcomes your specs
440
+ actually reach. At the end of the suite it reports, for every action, the
441
+ outcomes it can end with (success, its declared failures and skips) that no
442
+ spec produced. A stubbed result doesn't count. A committed baseline of known
443
+ gaps lets CI fail on a new gap or a stale entry, and sharded suites write one
444
+ file per shard and merge them in a later step. See USAGE, "Outcome coverage".
445
+
446
+ ```ruby
447
+ # spec/support/actionable_coverage.rb
448
+ require 'actionable/rspec/coverage'
449
+ Actionable::Coverage.loader = -> { Rails.application.eager_load! }
450
+ Actionable::Coverage.baseline = 'spec/actionable_coverage_baseline.yml' if ENV['CI']
451
+ ```
452
+
453
+ ## Background jobs (optional)
454
+
455
+ `require 'actionable/job'` adds a framework-agnostic helper that runs an action
456
+ inside a Sidekiq worker or ActiveJob job and maps its result to a queue
457
+ disposition (`:ack` / `:retry` / `:discard`):
458
+
459
+ ```ruby
460
+ require 'actionable/job'
461
+
462
+ class ProjectEventJob
463
+ include Sidekiq::Job # or ActiveJob::Base
464
+ include Actionable::Job::Mixin
465
+
466
+ def perform(event_id)
467
+ run_actionable(ProjectApiEvent, event_id: event_id)
468
+ end
469
+ end
470
+ ```
471
+
472
+ A success or skip acks; a transient failure raises `RetryableFailure` so the
473
+ queue retries; a permanent-code failure is discarded. Genuine exceptions
474
+ propagate unchanged. An action that declares `honor_retry_metadata` lets the
475
+ `retry:` metadata of its declared failures and skips decide instead
476
+ (`skips :not_ready, retry: true` then retries the job). See
477
+ [USAGE](USAGE.md#background-jobs).
478
+
479
+ ## Types (RBS)
480
+
481
+ The library ships `sig/actionable.rbs` (generated by Sord from YARD). For your
482
+ own actions, `Actionable::RBS.generate(YourAction)` emits a typed `.run`
483
+ signature, a typed `input` (as `InputSchema`), the output instance variables
484
+ your steps assign, and output accessors from the action's `input`/`output`
485
+ schemas.
486
+
487
+ **Adapter signatures are always declared.** `sig/` also types the Rails adapter
488
+ (`transactional`, `transaction_config`, `after_commit`, `after_commit_hooks`) on
489
+ `Actionable::Action`, the RSpec helpers, and `Actionable::Job`. RBS has no way to
490
+ make a signature depend on a `require`, and `rbs` loads a gem's whole `sig/`
491
+ directory, so a type checker sees those methods even in an app that never
492
+ requires `actionable/rails` — where calling them raises `NoMethodError`. Outside
493
+ Rails, don't rely on the type checker to catch a stray `transactional`.
494
+
495
+ ## Claude Code skill
496
+
497
+ The gem bundles a [Claude Code](https://claude.com/claude-code) skill
498
+ (`skills/actionable/SKILL.md`) that teaches the assistant the step/result DSL,
499
+ the control-flow verbs, the common mistakes, and how to debug a failed or skipped
500
+ run — so editing actions in a dependent project goes correctly.
501
+
502
+ **Install as a plugin** (recommended — the repo doubles as a single-plugin marketplace):
503
+
504
+ ```text
505
+ /plugin marketplace add Paymentbox-com/actionable
506
+ /plugin install actionable@actionable
507
+ ```
508
+
509
+ **Or copy it into your project** (no marketplace needed) — the skill ships in the gem package:
510
+
511
+ ```bash
512
+ mkdir -p .claude/skills/actionable
513
+ cp "$(bundle show actionable)/skills/actionable/SKILL.md" .claude/skills/actionable/
514
+ ```
515
+
516
+ Either way it triggers when you ask Claude to define, edit, or debug an
517
+ `Actionable::Action`. The skill points at [`USAGE.md`](USAGE.md) (also shipped)
518
+ for the exhaustive reference.
519
+
520
+ ## Performance
521
+
522
+ Indicative numbers from `bundle exec rake bench` (dependency-free script in
523
+ `benchmarks/`, 20,000 iterations after a warm-up; Ruby 4.0.7, YJIT off, Apple
524
+ M4, actionable 1.6.0 + the unreleased 1.7.0 changes). Use them to compare
525
+ changes on one machine, not as a promise for yours.
526
+
527
+ | Scenario | µs per run |
528
+ |---|---|
529
+ | Trivial action (one empty step) | 5.6 |
530
+ | 5-step action, `measure :none` (default) | 7.4 |
531
+ | 5-step action, `measure :all` | 11.5 |
532
+ | Input coercion, 5-field schema from strings | 20.4 |
533
+ | Nested action (parent + child, typed outputs) | 23.4 |
534
+ | Nested action, `measure :all` | 26.8 |
535
+ | `history.to_json` of a 5-step history | 3.6 to 4.4 |
536
+
537
+ The fixed cost of a run dominates; each extra step is a fraction of a
538
+ microsecond. Measuring adds about 4 µs to a 5-step run, so `measure :sampled`
539
+ keeps it off most production runs. Input coercion and typed output validation
540
+ cost more than the steps themselves.
541
+
542
+ ## Development
543
+
544
+ ```bash
545
+ bin/rspec # run the specs
546
+ bundle exec rake # specs + rubocop
547
+ bundle exec rake release:check # full pre-flight (specs, rubocop, sigs, docs)
548
+ bundle exec rake bench # indicative micro-benchmarks (not in the default task)
549
+ ```
550
+
551
+ See [`USAGE.md`](USAGE.md) for the dense, example-first reference.
552
+
553
+ ## License
554
+
555
+ [MIT](LICENSE.txt).