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/CHANGELOG.md ADDED
@@ -0,0 +1,766 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [1.9.0] - 2026-10-24
6
+
7
+ The gem is renamed **`actionable-rb`** so it can be published to rubygems.org,
8
+ where `actionable` belongs to an unrelated 2014 gem. Nothing in the library
9
+ changed: `require 'actionable'`, the `Actionable` module and every API stay the
10
+ same. Two lines in an app change: the Gemfile's `gem 'actionable'` becomes
11
+ `gem 'actionable-rb'`, and a `.rubocop.yml` that loads the cop with `plugins:`
12
+ renames its key to `actionable-rb`. Read `UPGRADING.md` "1.8.2 → 1.9.0".
13
+
14
+ ### Changed
15
+
16
+ - **The gem is renamed `actionable-rb`**, so it can be published to rubygems.org,
17
+ where `actionable` belongs to an unrelated 2014 gem. `require 'actionable'`,
18
+ the `Actionable` module and every API are unchanged. A new
19
+ `lib/actionable-rb.rb` lets `Bundler.require` load it by the gem name, and the
20
+ gemspec now pushes to rubygems.org instead of GitHub Packages.
21
+ **Upgrade note:** the Gemfile line becomes `gem 'actionable-rb'`, and a
22
+ `.rubocop.yml` that loads the cop with `plugins:` must use the `actionable-rb`
23
+ key. <!-- upgrading: gem-renamed-actionable-rb -->
24
+
25
+ ## [1.8.2] - 2026-10-23
26
+
27
+ Three fixes found by a mutation-driven review, plus upgrade-guide answers from
28
+ a dry run against a large app. All three change what an app can see, so read
29
+ `UPGRADING.md` "1.8.1 → 1.8.2" before bumping. `case_step` branches now honour
30
+ `if:` / `unless:`: a guarded-out branch does nothing, silently, where the guard
31
+ used to be ignored. `Actionable::RBS` generates valid class names for `?` / `!`
32
+ codes and raises on colliding ones. The first-run verb check sees through
33
+ `alias_method`, so it can raise on an aliased step it used to skip.
34
+
35
+ ### Fixed
36
+
37
+ - **`case_step` branches honour `if:` / `unless:`.** `on 'x', :handle, if:
38
+ :ready?` (and `default :y, unless: …`) accepted the guard and then ignored
39
+ it, so the branch always ran. The branch is still picked by value alone; its
40
+ guard now decides whether the target runs, as for a plain step. A matched
41
+ branch whose guard opts out does nothing: no fall-through to the next branch
42
+ or the `default`. **Upgrade note:** a branch you guarded now really skips.
43
+ <!-- upgrading: case-branch-guards -->
44
+ - **`Actionable::RBS` names output classes for `?` / `!` codes validly.**
45
+ Under `typed_outcomes`, `fails(:expired?) { … }` emitted `class
46
+ Expired?FailureOutput`, which RBS can't parse. A trailing `?` / `!` now
47
+ becomes `Predicate` / `Bang` (`ExpiredPredicateFailureOutput`,
48
+ `DoneBangSkipOutput`), so `:x` and `:x?` get different classes. `generate`
49
+ now raises `ArgumentError` when two codes of one kind would share a class
50
+ name (`:ready` / `:ready_`), which silently emitted one class for two
51
+ schemas, or when a code can't start a class name. **Upgrade note:** rename
52
+ one of two clashing codes. <!-- upgrading: rbs-outcome-class-names -->
53
+ - **The first-run verb check sees through `alias_method`.** A step naming an
54
+ alias (`step :charge` with `alias_method :charge, :do_charge`) looked up
55
+ the def by the alias name, found none, and skipped the check; it now reads
56
+ the original `def`, so a misplaced verb there raises `DefinitionError` like
57
+ any other step. Documented limit: a module `prepend`ed over a step method
58
+ hides the class's own `def` from the check. **Upgrade note:** fix the
59
+ reported verb, as for any verb-position offense.
60
+ <!-- upgrading: verb-check-alias -->
61
+
62
+ ### Documentation
63
+
64
+ - **UPGRADING.md, from a dry run against a large app (133 actions).** The guide
65
+ now says which six Finds must run on a green suite (silent behaviour changes)
66
+ and that the rest cover code the suite doesn't reach. The `describe` Find skips
67
+ `RSpec.describe` (1,552 hits before). The code Finds skip receivers and
68
+ non-Ruby files. The `@result` entry says a `Result` you build yourself is still
69
+ allowed, with `halt!`. A new section lists the class-level API unchanged since
70
+ 1.5.1. The cop entry says to list every base class defined in its own file. The
71
+ opt-out goes in `spec_helper`, not an initializer. The effort estimate counts
72
+ suite runs, and there is a PCRE2 check. AGENTS.md and the skill follow.
73
+
74
+ ## [1.8.1] - 2026-10-23
75
+
76
+ Housekeeping. The `Actionable/VerbNotLast` cop resolves superclass names the way
77
+ Ruby does, so same-named classes in one file no longer confuse it; the
78
+ UPGRADING.md drift guard is fixture-tested and now asks for an explicit
79
+ decision on every visible CHANGELOG entry. No runtime behaviour changes.
80
+
81
+ ### Changed
82
+
83
+ - **`Actionable/VerbNotLast` resolves a superclass by its lexical full name.**
84
+ It matched by the last name segment, so in one file `module B; class Base;
85
+ end; class X < Base` counted as an action when `module A` held a `Base <
86
+ Actionable::Action`, and a file's own `Legacy::ApplicationAction` counted as
87
+ the listed base. It now looks the name up as Ruby does (enclosing `module` /
88
+ `class` outwards, a compact `class A::B` opens only `A::B`, `::X` is the
89
+ top level), so those classes are left alone, and a listed full name is
90
+ found from inside its namespace (`module Ops; class X < BaseAction` with
91
+ `Ops::BaseAction` listed). An offense it newly reports is a call the
92
+ first-run check already raises on. <!-- upgrading: none -->
93
+
94
+ ### Tests / CI
95
+
96
+ - **The UPGRADING.md drift guard asks for a decision on every visible
97
+ change.** Each `[Unreleased]` bullet under `### Changed`, `### Removed` or
98
+ `### Breaking…` must carry an `upgrading: <slug>` marker comment (with its
99
+ guide entry, as before) or `upgrading: none`, so a change filed there
100
+ without an upgrade note no longer slips past it. Released sections are not
101
+ re-checked. The guard's own examples now run on fixtures, so they hold in
102
+ any release state; one example still checks the live files.
103
+
104
+ ## [1.8.0] - 2026-10-22
105
+
106
+ A guard against silently continuing runs, plus upgrade guidance. A plain
107
+ `fail` / `fail_with` / `skip` / `succeed` must now be the last thing its step
108
+ or hook does — checked once per action class on first run (Prism; turn it off
109
+ with `Actionable.verb_position_check = false`) and by a shipped RuboCop cop,
110
+ `Actionable/VerbNotLast`. `fail` / `skip` codes must be identifiers, so the
111
+ `fail 'card expired'` Kernel habit raises instead of recording a code. Both can
112
+ raise on code that used to run: read `UPGRADING.md`, which walks 1.5 → 1.8
113
+ change by change. CI now also runs real ActiveJob/Sidekiq and ActiveRecord 7.1
114
+ lanes. Still requires field_struct >= 0.12.
115
+
116
+ ### Added
117
+
118
+ - **A plain recording verb must end its step or hook method (owner rulings
119
+ R5, R10).** `fail :card_expired if card.expired?` followed by more code keeps
120
+ running — almost always a `fail!` missing its bang. On an action's first
121
+ run, every step method (case-step branch targets included) and lifecycle
122
+ hook method (`on_success` / `on_failure` / `on_skip` / `always`, and
123
+ `after_commit` with the Rails adapter) is parsed with Prism, and a plain
124
+ `fail` / `fail_with` / `skip` / `succeed` (or `self.fail` …) that isn't the
125
+ method's last expression raises `DefinitionError` naming the action, method, `file:line`,
126
+ verb and fix. Branches, `return fail(...)`, a verb followed by `return`,
127
+ and blocks whose call is last are allowed; USAGE (Control flow, "Where a
128
+ plain verb may go") has the full table. Checked once per class: later runs
129
+ pay one instance-variable read. Skipped without Prism >= 1.0 (Ruby 3.2
130
+ without the `prism` gem; no new dependency) and for methods with no
131
+ parseable source (`eval`, `define_method`).
132
+ - **`Actionable.verb_position_check = false`** turns that first-run check off
133
+ for the process (on by default; `Actionable.verb_position_check?` reads it).
134
+ Off, `run` never parses source or loads Prism. It is an escape hatch while
135
+ upgrading, and leaves the cop as the only guard. The identifier check on
136
+ `fail` / `skip` codes stays on: it's a per-call regex, not a parse.
137
+ - **`Actionable/VerbNotLast`, an opt-in RuboCop cop** with the same rules
138
+ (one shared analyzer). Not loaded by `require 'actionable'`; enable it with
139
+ `plugins: [{actionable: {require_path: actionable/rubocop}}]` (RuboCop >=
140
+ 1.72, via the gem's lint_roller plugin) or `require: [actionable/rubocop]`.
141
+ It checks a class whose superclass is an action base and that declares
142
+ `step` / `case_step`, and the methods that class names in its declarations.
143
+ An action base is one of the cop's `ActionBaseClasses` (default
144
+ `Actionable::Action`, `ApplicationAction`) or a class defined in the same
145
+ file whose superclass chain reaches one, so a dry-transaction class with
146
+ its own `step` gets no false positives (owner ruling R10).
147
+ - `rake bench` gains `fail! action` and `skip! action` scenarios.
148
+
149
+ ### Changed
150
+
151
+ - **`fail` / `skip` codes must be identifiers.** A code that isn't a Symbol
152
+ or String matching `/\A[[:alpha:]_][[:alnum:]_]*[?!]?\z/` (`'card
153
+ expired'`, `:"not-found"`, `'payment.declined'`, a class) raises
154
+ `ArgumentError` at the call, suggesting `raise` for `Kernel#fail` habit.
155
+ Applies to `fail` / `fail!` / `fail_with` / `fail_with!` / `skip` / `skip!`.
156
+
157
+ **Upgrading:** both checks raise on code that used to run. <!-- upgrading: verb-position-guard --> <!-- upgrading: identifier-codes -->
158
+ - `DefinitionError` "… calls `fail` with more code after it …" on an action's
159
+ first run: if the code after the verb should not run, use the bang verb
160
+ (`fail!`); if it should, move the verb to the end of the method (or
161
+ `return fail(...)`). Run the cop over your app to find them all up front.
162
+ If you can't fix them all before deploying, set
163
+ `Actionable.verb_position_check = false` in an initializer until you have.
164
+ - `ArgumentError` "… code "…" is not an identifier": use a Symbol code and
165
+ pass the sentence as the message (`fail(:card_expired, 'card expired')`),
166
+ or `raise` if you meant an exception.
167
+
168
+ ### Tests / CI
169
+
170
+ - **Real-backend lanes.** Two new CI jobs feed the required `ci` check:
171
+ `jobs (ActiveJob + Sidekiq)` runs the suite with `gemfiles/jobs.gemfile`, so
172
+ `spec/actionable/job_backends/` proves `Job` + `honor_retry_metadata` on a
173
+ real ActiveJob (`retry_on Actionable::Job::RetryableFailure`: `retry: true`
174
+ re-enqueues until attempts run out, `retry: false` discards or acks, the
175
+ default is unchanged) and that `RetryableFailure` propagates out of a Sidekiq
176
+ job in inline testing mode (no Redis). `activerecord 7.1` runs the suite on
177
+ ActiveRecord 7.1 (`gemfiles/activerecord_7_1.gemfile`, Ruby 3.3):
178
+ `transactional` and savepoints work there, and declaring `after_commit`
179
+ raises the `DefinitionError` naming 7.2. These specs skip in the default
180
+ suite, where the gems or versions they need are absent.
181
+
182
+ ### Documentation
183
+
184
+ - **`UPGRADING.md`: how to move an app from 1.5.x to 1.8.0** (owner ruling
185
+ R9). A TL;DR with the order of work (field_struct first, then actionable,
186
+ the cop, RBS), a straight 1.5-to-1.8 path, a symptom index, and a Symptom /
187
+ Find / Fix / Why entry for every change a caller can see, each Find an `rg`
188
+ pattern checked against its "before" code or the tool that finds it. AGENTS.md
189
+ and the skill gain a numbered upgrade procedure for agents; README and
190
+ getting_started link to it, and the gem ships it. `spec/upgrading_spec.rb`
191
+ keeps it honest: every CHANGELOG upgrade note since 1.5.2 carries an
192
+ invisible `upgrading:` slug comment that the guide must carry in that
193
+ version's section, and the guide's declared range must reach the next
194
+ release. Its runnable examples are doctested.
195
+ - README "Types (RBS)" says the adapter signatures (`transactional`,
196
+ `after_commit`, the RSpec helpers, `Job`) are declared on `Actionable::Action`
197
+ even when `actionable/rails` isn't required: RBS can't make a signature
198
+ conditional and `rbs` loads a gem's whole `sig/`.
199
+
200
+ ## [1.7.0] - 2026-10-22
201
+
202
+ Lifecycle and adapter hardening. `always` hooks now run even after an outcome
203
+ hook halts; redeclaring a step or hook overrides it in place; the Rails adapter
204
+ gains an opt-in `after_commit` hook (ActiveRecord >= 7.2) that runs only after
205
+ the outermost transaction commits; `Job` can opt in to honouring a failure's or
206
+ skip's declared `retry:`; the Rails, Job and RSpec surfaces now ship RBS. Two
207
+ behaviour changes have upgrade notes under Changed. Still requires
208
+ field_struct >= 0.12.
209
+
210
+ ### Added
211
+
212
+ - **`benchmarks/` and `rake bench`.** A dependency-free script (monotonic
213
+ clock, no `benchmark` / benchmark-ips) times a trivial run, a 5-step run with
214
+ `measure :none` and `:all`, 5-field input coercion, a nested action with and
215
+ without measurement, and `history.to_json`. `BENCH_ITERATIONS` sets the loop
216
+ count. Not in the default task and not packaged in the gem; a smoke spec
217
+ runs it with 5 iterations so it can't rot. README has a "Performance"
218
+ section with indicative numbers.
219
+
220
+ ### Documentation
221
+
222
+ - **`fail` is not `raise` inside an action.** USAGE (Control flow), README,
223
+ getting_started, AGENTS.md and the skill now warn that the gem's `fail` verb
224
+ replaces `Kernel#fail` in an action's instance methods: `fail 'boom'`
225
+ records a `Failure` with code `:"boom"` and the run continues, and
226
+ `fail SomeError, 'msg'` raises `NoMethodError`. Use `raise` for exceptions
227
+ and `fail!(:code)` to stop with a failure. Behaviour is unchanged.
228
+ - README, getting_started, AGENTS.md and the skill now mention the 1.7.0
229
+ topics where they list their neighbours: step/hook redeclaration (last
230
+ wins), `always` hooks running after a hook halt, `after_commit`, and
231
+ `honor_retry_metadata`, each linking to USAGE.
232
+
233
+ - **RBS for the adapters' public surface (owner ruling P11).** `sig/` now <!-- upgrading: adapter-rbs -->
234
+ declares what clients call from the adapters: `transactional` /
235
+ `transaction_config` and `after_commit` / `after_commit_hooks` on
236
+ `Actionable::Action` (Rails adapter), and the RSpec matchers
237
+ (`perform_actionable` and its chain) and stubs (`allow_actionable_*`,
238
+ `stub_actionable_*`, `actionable_*_result`). They are generated from YARD by
239
+ sord like the core's (`.yardopts` now excludes only `ProxyValidator`, the
240
+ Railtie and `actionable/rspec/coverage`); the one runtime mix-in YARD cannot
241
+ see, `after_commit`'s singleton-class prepend, is declared by hand in
242
+ `sig/actionable/rails.rbs`. A spec compares each adapter type's public
243
+ methods, by reflection, with its RBS so neither can drift. `Actionable::Job`
244
+ was already generated. The report-only Steep lane now checks the adapters
245
+ too.
246
+ - **`describe` / `describe_text` list `after_commit` hooks.** With the Rails <!-- upgrading: describe-after-commit -->
247
+ adapter loaded, `describe[:hooks][:after_commit]` names them, and
248
+ `describe_text` adds an `after_commit:` hooks line when there are any.
249
+
250
+ - **`after_commit` hook kind (Rails adapter).** After `require 'actionable/rails'`,
251
+ `after_commit :method` (or a nested action, with `:if` / `:unless` /
252
+ `:input` like any hook) runs a side effect only after the database work
253
+ really committed. It is registered with
254
+ `ActiveRecord.after_all_transactions_commit` when a run ends in a `Success`,
255
+ so it waits for the **outermost** open transaction (not the action's own
256
+ savepoint), runs immediately when none is open, and never runs if an
257
+ enclosing transaction rolls back. A `Failure` or `Skipped` never runs it.
258
+ The run is over by then: the hook can't change the result, isn't recorded in
259
+ the history, and an exception it raises propagates after the commit (from
260
+ the outer `transaction` call, or `.run` when no transaction was open).
261
+ Needs ActiveRecord >= 7.2; declaring it on an older one (or without
262
+ ActiveRecord loaded) raises a `DefinitionError`. Existing hooks and the
263
+ core are unchanged.
264
+
265
+ - **`honor_retry_metadata`: let `Actionable::Job` act on declared `retry:`
266
+ metadata.** An action that calls it (the macro comes with
267
+ `require 'actionable/job'`) has the `retry:` key of its declared failures and
268
+ skips decide the job's disposition: `retry: true` retries (a skip or a
269
+ permanent-code failure included) and `retry: false` does not (a failure is
270
+ discarded, a skip acks). Codes without the key map as before. Retried skips
271
+ raise the same `RetryableFailure`, carrying the `Skipped`. Inherited; a
272
+ subclass backs out with `honor_retry_metadata(enabled: false)`.
273
+ `Actionable::Job.disposition` takes the matching `honor_retry:` keyword.
274
+ Off by default, so metadata stays neutral (D20/D21) and existing jobs behave
275
+ exactly as before.
276
+
277
+ ### Changed
278
+
279
+ - **Redeclaring a step or hook now overrides it.** A second `step :x`, <!-- upgrading: step-redeclaration -->
280
+ `case_step :x`, nested `step SomeAction`, or `on_success` / `on_failure` /
281
+ `on_skip` / `always :x` with the same type and name, in the same class or a
282
+ subclass, replaces the earlier declaration's options (guards, `:input` /
283
+ `:expose`, case branches) and keeps the earlier position. Before, the
284
+ redeclaration was silently dropped and the first declaration's options won,
285
+ so a subclass could not override an inherited step as invariant 5 promises.
286
+ A subclass's override never changes the parent. **Upgrade note:** an action
287
+ that redeclares a step with different options now runs the last options.
288
+
289
+ - **`always` hooks now run even after an outcome hook halts.** A bang verb <!-- upgrading: always-after-halt -->
290
+ (`fail!` / `skip!` / `succeed!`), `halt!`, or a failing nested action inside
291
+ an `on_success` / `on_failure` / `on_skip` hook still has its result adopted
292
+ and still stops the remaining hooks of that category, but it no longer skips
293
+ the `always` hooks: they run next and see the adopted result. Before, one
294
+ halt stopped every remaining hook, so cleanup and audit hooks could be
295
+ silently skipped. A halt inside an `always` hook is adopted the same way and
296
+ stops only the remaining `always` hooks. Under `transactional`, `always`
297
+ hooks still run inside the transaction and the adopted result still decides
298
+ commit or rollback, so an `always` hook's writes roll back with a failed run.
299
+ When measuring, those `always` hooks appear in the history as usual.
300
+ **Upgrade note:** an `always` hook that assumed it would not run after a hook
301
+ failure must now check `result` itself.
302
+
303
+ ## [1.6.0] - 2026-10-22
304
+
305
+ Valid, guarded RBS. The gem's own signatures can only get more typed (CI
306
+ ratchet, `untyped` 46 → 27); generated RBS for your actions declares the real
307
+ superclass chain, a typed `InputSchema`, typed `.run` keywords and output
308
+ ivars, built on field_struct 0.12's `FieldStruct::RBS.type_for`. Requires
309
+ field_struct >= 0.12. No runtime behaviour changes.
310
+
311
+ ### Added
312
+
313
+ - **`Actionable::RBS.generate` types an action's input and output instance <!-- upgrading: rbs-output-ivars --> <!-- upgrading: rbs-input-schema -->
314
+ variables** (generated RBS only; no runtime change). For an action with a
315
+ fixed `input` schema it emits a nested `class InputSchema < ::FieldStruct::Base`
316
+ with a typed reader and writer per input field, `def input: () -> InputSchema`,
317
+ and a `.run` overload `| (InputSchema input) -> ...` beside the keyword form.
318
+ `InputSchema` exists only in RBS (the runtime schema is anonymous), so
319
+ generation raises `ArgumentError` when Ruby already resolves
320
+ `YourAction::InputSchema` (a constant on the action, an ancestor or an
321
+ included module). It also declares each output field as a nullable
322
+ instance variable (`@total: ::Integer?`), so a step that assigns
323
+ `@total = ...` type-checks. **Upgrade note:** the generated RBS now owns
324
+ those instance variables; delete any declaration of them from your own sig
325
+ files, since RBS rejects an instance variable declared twice.
326
+
327
+ ### Fixed
328
+
329
+ - **`Actionable::RBS.generate` declares the real superclass chain.** An action <!-- upgrading: rbs-superclass-chain -->
330
+ under an intermediate base (`class Codes::DecideMapping < ApplicationAction`)
331
+ was declared `< ::Actionable::Action`, hiding its bases from the type
332
+ checker, and a host that declared the real superclass got
333
+ `NoSuperclassFoundError` / a failing `rbs validate` when the base had no
334
+ signature. The action now names its nearest named superclass, and every
335
+ named ancestor up to `Actionable::Action` is declared first, member-less
336
+ (a typed one with its empty result classes), so the RBS resolves on its own.
337
+ The declarations repeat byte-identically across files and merge. A namespace
338
+ that is a class now opens as `class`, not `module`, which RBS rejected as a
339
+ duplicate declaration.
340
+ **Upgrade note:** if your own sig files declare one of those bases with a
341
+ different superclass (e.g. `class ApplicationAction < ::Actionable::Action`
342
+ while Ruby says otherwise), RBS now reports a superclass mismatch; make your
343
+ declaration match Ruby's ancestry or drop it.
344
+
345
+ ### Changed
346
+
347
+ - **Optional `.run` keywords are nullable in generated RBS** (`?note: ::String?`): an optional field may be passed `nil` explicitly, and the run succeeds.
348
+ - **A required input field with a default is an optional `.run` keyword** (`?amount: ::Integer`), since `run` succeeds without it. An action whose input is chosen by an `input_for` dispatch (including one inherited under a fixed `input`) gets `.run: (*untyped)`, since the dispatch decides the keywords at run time.
349
+ - **Requires field_struct >= 0.12.** The generator now takes every field type <!-- upgrading: field-struct-0-12 --> <!-- upgrading: rbs-stricter-types -->
350
+ from `FieldStruct::RBS.type_for` instead of its own copy of the mapping, so
351
+ generated action RBS matches `FieldStruct::RBS.generate`. Consumer-visible
352
+ changes to generated RBS: writers on `Output` and the per-code output
353
+ classes are typed `(T? value) -> T?` instead of `(untyped value) ->
354
+ untyped`; scalar array elements are nullable (`::Array[::String?]`); a
355
+ `:value` field is `untyped`, never `untyped?`; a bare generic `ruby_type`
356
+ gets type arguments (`::Hash[untyped, untyped]`); and a field's `rbs_type:`
357
+ override is honoured. A field typed by an anonymous nested struct class now
358
+ raises `ArgumentError` (it previously emitted an invalid `::` name).
359
+ - **The committed signatures can only get more typed.** Contributor tooling
360
+ only; the shipped `sig/actionable.rbs` is unchanged. `rake sigs:untyped`
361
+ (part of the default task) fails when sig/ holds more or fewer than the
362
+ committed ceiling of 46 non-comment `untyped`, so a typing change lowers
363
+ the ceiling with it. `sigs:generate` / `sigs:check` fail on a sord
364
+ `[OMIT]`/`[DUCK]` fallback that isn't in the committed allowlist (24
365
+ today) and on allowlist entries sord no longer emits. sord runs without
366
+ `--replace-errors-with-untyped` (the output is identical) and fails on any
367
+ `SORD_ERROR_` type. `sigs:validate` validates the whole sig/ directory.
368
+ - **Optional, report-only Steep 2.x** via `gemfiles/types.gemfile` and a
369
+ `types` CI job on Ruby 3.4 that never fails the build. The Steepfile now
370
+ checks the core only (the RSpec and Rails adapters are ignored); Steep is
371
+ no longer in the main Gemfile.
372
+ - **More precise signatures** (RBS only; no runtime change). Keyword splats
373
+ are typed per value (`**Object options`, not `**::Hash[Symbol, Object]
374
+ options`, which RBS reads as "every value is a Hash"); `.run`, `#initialize`
375
+ and `Job::Mixin#run_actionable` take `*Object, **Object`; the gem's
376
+ constants (`Actionable::HALT`, `VERSION`, `Action::GEM_FAILURE_CODES`, …)
377
+ are declared; and the halt/measurement helpers declare their block. The
378
+ DSL methods whose block runs with another `self` (`output`, `input`,
379
+ `fails`, `skips`, `case_step`, `input_for`, and the `define` builders)
380
+ gain a block overload with its self-type in the new
381
+ `sig/actionable/dsl.rbs`. `untyped` in sig/ falls from 46 to 27, and the
382
+ sord fallback allowlist from 24 entries to 4.
383
+
384
+ ## [1.5.2] - 2026-10-22
385
+
386
+ Runner and nested-action fixes: hooks that halt, kwargs that survive hooks,
387
+ nested actions that neither wipe parent ivars nor commit failed writes.
388
+ Requires field_struct 0.11.0 or later.
389
+
390
+ ### Changed
391
+
392
+ - **Requires field_struct >= 0.11.** It is the release whose numeric and input <!-- upgrading: field-struct-0-11 -->
393
+ fixes this one is tested against; CI now pins it by tag.
394
+ - **CI runs Ruby 3.2, 3.3, 3.4 and 4.0** (was 3.3.6 only), plus the docs guard,
395
+ behind one aggregate `ci` check that `main` requires.
396
+ - **The packaged gem leaves out contributor-only files** (`.claude/`,
397
+ `CLAUDE.md`, `docs/origin/`, Rakefile/Steepfile/lint config). It still ships
398
+ `lib/`, `sig/`, the user docs, `AGENTS.md` and the Claude skill.
399
+
400
+ ### Fixed
401
+
402
+ - **A failed `transactional` action inside an outer transaction no longer <!-- upgrading: transactional-savepoint -->
403
+ commits its writes.** It joined the outer transaction, whose commit ignored
404
+ the action's rollback, so a recorded `Failure` still committed its rows
405
+ (measured on ActiveRecord 8.1 + SQLite). Inside an open transaction the action
406
+ now takes its own savepoint (`requires_new: true`) unless `requires_new:` is
407
+ given; `requires_new: false` keeps the old joining behaviour.
408
+ - **A nested action no longer wipes parent ivars it never set.** Absorbing a <!-- upgrading: nested-absorb-set-fields --> <!-- upgrading: stub-set-fields -->
409
+ successful or skipped child copied every declared output field, so a field
410
+ the child left nil (or at its default) overwrote the parent's value. Only
411
+ fields the child actually set are absorbed now; `expose:` still narrows them.
412
+ - **Private step, hook and `case_step` methods work.** The definition check <!-- upgrading: core-method-steps -->
413
+ already accepted them, but the step then raised `NoMethodError`; they are
414
+ now called like guards are. A step or hook named after a method the action
415
+ only inherits from Ruby's core (`step :puts`, `always :exit`) raises
416
+ `DefinitionError` up front instead of calling `Kernel`.
417
+ - **`Action.input` without a block reads the schema** instead of replacing it
418
+ with an empty one, matching `output`.
419
+ - **A step that assigns `@result`** gets a clear `DefinitionError` instead of a <!-- upgrading: result-ivar -->
420
+ `NoMethodError` deep in the runner.
421
+ - **`History#to_json` requires Oj itself** rather than relying on another file
422
+ having loaded it.
423
+
424
+ - **A bang verb inside a lifecycle hook now sets the result.** `fail!`, <!-- upgrading: hook-bang-verb -->
425
+ `skip!` or `succeed!` in a hook used to be dropped: `fail!` in `on_success`
426
+ returned `Success`, committed a `transactional` action and acked the job.
427
+ The hook's result is now adopted before the halt stops the remaining hooks,
428
+ as the docs always said. This includes a nested action used as a hook
429
+ (`on_success ChildAction`): a failing child now fails the run and stops the
430
+ remaining hooks.
431
+ - **`succeed`/`skip` kwargs survive lifecycle hooks.** After any hook <!-- upgrading: hook-kwargs -->
432
+ refreshed the output, the kwargs were lost: a required field set only by
433
+ kwarg turned the run into `Failure(:invalid_output)`, and a kwarg that
434
+ overrode an ivar silently reverted to the ivar's value. Present since 1.0.0.
435
+ A `Failure(:invalid_output)` also keeps the output that failed validation
436
+ across hooks instead of having it rebuilt from ivars.
437
+ - **No spurious `errors[:output]` on a failure or skip.** The best-effort, <!-- upgrading: no-spurious-output-error -->
438
+ unvalidated output of a non-typed failure or skip added
439
+ `output: ["is invalid"]` to the result's errors. It no longer does; errors
440
+ the failure recorded itself are kept.
441
+
442
+ ## [1.5.1] - 2026-10-21
443
+
444
+ ### Added
445
+
446
+ - **`Coverage.new_gap_hint`** sets the advice the baseline-mismatch message
447
+ gives for a new gap. The default (`Coverage::DEFAULT_NEW_GAP_HINT`) is
448
+ unchanged; a host whose baseline only shrinks can drop "or add it to the
449
+ baseline".
450
+
451
+ ### Fixed
452
+
453
+ - **RBS for a subclass of a typed action now resolves on its own.** Its result
454
+ classes subclass the parent's (`< ::ApiAuth::BaseAction::Success`), which
455
+ nothing declared when the parent is an abstract base that is never
456
+ generated. `RBS.generate` now declares those parent classes, empty, up the
457
+ chain of typed ancestors.
458
+ - **RBS readers on `Failure` are nullable.** An `invalid_output` failure
459
+ carries the output that broke its schema, so a required field can be
460
+ missing; the readers were typed non-nil.
461
+
462
+ ## [1.5.0] - 2026-10-21
463
+
464
+ Typed outcomes (decision D23). Opt-in with `typed_outcomes`: an action that
465
+ doesn't turn it on runs as before, except that a code whose `fails` or `skips`
466
+ declaration carries a block (new syntax) has its output validated.
467
+
468
+ ### Added
469
+
470
+ - **`typed_outcomes`** turns typed outcomes on for an action and its
471
+ descendants (`typed_outcomes enabled: false` turns them off for a subclass);
472
+ `typed_outcomes?` reads it.
473
+ - **A block on `fails` / `skips`** declares the output a result with that code
474
+ carries, in the `output` DSL. **`failure_output_schema(code)`** and
475
+ **`skip_output_schema(code)`** read it back, and `describe` shows its fields.
476
+ Such output is built from the call's keywords alone, never from instance
477
+ variables, and validated: output that breaks the schema ends the run as
478
+ `invalid_output`. The block also ends instance-variable capture for that
479
+ code, typed outcomes or not.
480
+ - **`output:` on `fail` / `fail!` / `fail_with` / `fail_with!`**, under typed
481
+ outcomes only: the failure's output, a Hash or a FieldStruct
482
+ (`ArgumentError` otherwise). Without them `output:` on `fail` is still an
483
+ error, and `fail_with` refuses it. Under typed outcomes, output for a code
484
+ with no schema raises `DefinitionError`, naming the code.
485
+ - **Per-action result classes.** Under typed outcomes `.run` returns the
486
+ action's own `Success`, `Failure` or `Skipped`, each a subclass of the gem
487
+ class (or of the parent action's own). A nested failure becomes the parent's
488
+ own `Failure`, judged by the parent's schema for its code; a parent without
489
+ typed outcomes gets a plain `Actionable::Failure`. The RSpec stub helpers
490
+ return them too, with output shaped through the code's schema (or empty
491
+ without one) as a real run would.
492
+ - **RBS:** `Actionable::RBS.generate` types a typed action's `.run` as
493
+ `(Success | Skipped | Failure)`, with each `code` and `output` narrowed and
494
+ the output's readers declared on each class. A host's own result subclass,
495
+ which `.run` still returns as it is, is not described: a known gap.
496
+
497
+ ### Changed
498
+
499
+ - Under typed outcomes, a skip or failure whose code has no schema carries an
500
+ empty output, where it used to carry a best-effort capture into the success
501
+ schema. The gem's own `invalid_input` / `invalid_output` keep theirs.
502
+ - Under typed outcomes, results compare by class: `Act::Success` is not `==`
503
+ to an `Actionable::Success` with the same output, and the stub helpers
504
+ return a new object of the action's own class. Specs that compare results
505
+ with `==` need to compare against the action's class.
506
+
507
+ ## [1.4.0] - 2026-10-21
508
+
509
+ Declared skips (decision D21), the skip counterpart of 1.3.0's declared
510
+ failures. Additive: an action that declares no skips runs as before, and a
511
+ child skip still continues its parent. One edge can reach existing code, listed
512
+ under Changed: a skip's `to_h`, `inspect` and `attributes` have a `metadata`
513
+ key.
514
+
515
+ Outcome coverage for test suites (decision D22). Opt-in with
516
+ `require 'actionable/rspec/coverage'`; nothing changes for a suite that
517
+ doesn't require it.
518
+
519
+ ### Added
520
+
521
+ - **`skips(code, message = nil, **metadata)`** declares a skip code the action
522
+ can end with, plus neutral per-code metadata, the skip counterpart of `fails`
523
+ (decision D21). Declarations are inherited and a subclass can add to them.
524
+ **`declared_skips`** reads them back as `{code => metadata}`, and
525
+ **`skip_declarations`** as `{code => {message:, metadata:}}`.
526
+ - **`Skipped#metadata`** (default `{}`): a `skip` or `skip!` with a declared
527
+ code carries its metadata, and its declared message when the call gives none.
528
+ `Skipped#to_h` includes `metadata`.
529
+ - **Strict once declared.** An action that declares at least one skip code may
530
+ only skip with declared codes; any other `skip` / `skip!` raises
531
+ `Actionable::DefinitionError` at the call, including the implicit default
532
+ `:skipped`. An action that declares no skips is unchecked, and declared
533
+ failures don't make skips strict. **`allows_skip_code?(code)`** answers
534
+ whether an action may skip with a code.
535
+ - **`describe` shows declared skips** as `skips: {code => {message:,
536
+ metadata:}}`, and `describe_text` lists them under `Skips:`, after
537
+ `Failures:`.
538
+ - **RSpec support follows the skip declarations.** On an action that declares
539
+ skips, `allow_actionable_skip` / `stub_actionable_skip` build a skip carrying
540
+ the code's declared metadata (and message, when none is given), and they and
541
+ `and_skip` raise `ArgumentError` for a code the action doesn't declare,
542
+ `:skipped` included. `actionable_skip_result` takes `metadata:`.
543
+ - **`Actionable::RBS` emits a `skip_code` type alias** for an action that
544
+ declares skips, listing the declared codes, after `failure_code` when both
545
+ are declared.
546
+ - **`Actionable::Coverage`** records every real `.run` of a named action as
547
+ `[class name, outcome, code]`, nested runs and `run_each` included. It hooks
548
+ `Action.run` by prepending onto its singleton class, so an RSpec stub of
549
+ `.run` replaces it and a stubbed result never counts as the action tested.
550
+ - **The RSpec stub helpers note their runs** in `Coverage.stubbed` when
551
+ coverage is loaded, so a report can show an outcome that was only ever
552
+ stubbed. They record when the stub answers a call, not when it is set up.
553
+ - **`Coverage.expected_outcomes(action)`** lists what an action can end with,
554
+ read from its declarations at run time: `success`, every `declared_failures`
555
+ code, every `declared_skips` code, and any
556
+ `Coverage.gem_codes` that apply (`:invalid_input` when an input field is
557
+ required, `:invalid_output` when an output field is). `gem_codes` defaults
558
+ to `[]`.
559
+ - **`Coverage.actions`** lists the actions to report: named descendants of
560
+ `Coverage.root` (default `Actionable::Action`) that have steps and whose
561
+ name still resolves to them, filtered by `include_prefixes` and
562
+ `exclude_prefixes`. `Coverage.loader` runs first so a host can eager-load.
563
+ - **`Coverage::Report`** compares the two: `rows` and `totals` (actions,
564
+ expected outcomes, reached, missing), a text table (`to_s`) of each action's
565
+ never-reached outcomes with a "Stubbed only" column, and `to_json`.
566
+ `Coverage.only_observed = true` reports only actions with a real run, for a
567
+ partial or targeted run.
568
+ - **A named baseline of known gaps.** `Coverage.baseline = path` points at a
569
+ committed JSON or YAML file of `{"Action::Name" => [outcome keys]}`. Missing
570
+ outcomes alone never fail; with a baseline, the report fails on a gap the
571
+ baseline doesn't list and on an entry that is covered, no longer declared,
572
+ or names an action that is gone, and its message lists each entry to add
573
+ or remove. `Report#to_baseline` and `#write_baseline` produce the file.
574
+ - **Sharded suites.** `Coverage.write(path)` dumps a shard's runs as JSON and
575
+ `Report.new(files:)` merges any number of them (a glob that matches no file
576
+ raises rather than reporting everything missing). `Coverage.report!` prints
577
+ the report, writes JSON to `ACTIONABLE_COVERAGE_JSON` when set, rewrites
578
+ the baseline under `ACTIONABLE_COVERAGE_UPDATE_BASELINE=true`, and otherwise
579
+ raises `Coverage::BaselineMismatch` when the gaps differ from it.
580
+ - **`require 'actionable/rspec/coverage'`** turns it all on: it starts
581
+ recording and adds an RSpec `after(:suite)` hook that writes the shard file
582
+ named by `ACTIONABLE_COVERAGE_FILE` when that is set, and otherwise prints
583
+ the report and fails the suite on a baseline mismatch.
584
+
585
+ ### Changed
586
+
587
+ - **`Skipped#to_h` includes `metadata`** (`{}` unless the code is declared),
588
+ so code comparing a skip's `to_h` to an exact Hash sees one more key.
589
+ `Skipped#inspect` and `#attributes` show it too.
590
+
591
+ ## [1.3.0] - 2026-10-21
592
+
593
+ Declared failures (decision D20). Additive: an action that declares no
594
+ failures runs as before. Three edges can reach existing code, listed under
595
+ Changed: `metadata` is now a reserved output name, `Failure#to_h` has a
596
+ `metadata` key, and a nested failure no longer passes through an overridden
597
+ `fail`.
598
+
599
+ ### Added
600
+
601
+ - **`fails(code, message = nil, **metadata)`** declares a failure code the
602
+ action can end with, plus neutral per-code metadata (arbitrary keywords the
603
+ gem stores and passes through but never interprets). Declarations are
604
+ inherited and a subclass can add to them. **`declared_failures`** reads them
605
+ back as `{code => metadata}`.
606
+ - **`Failure#metadata`** (default `{}`): a `fail`, `fail!`, `fail_with` or
607
+ `fail_with!` with a declared code carries its metadata, and its declared
608
+ message when the call gives none. Declaring `:invalid_input` or
609
+ `:invalid_output` attaches metadata to the failures the gem builds itself.
610
+ `Failure#to_h` includes `metadata`.
611
+
612
+ - **`describe` shows declared failures** as `failures: {code => {message:,
613
+ metadata:}}`, and `describe_text` lists them under `Failures:`.
614
+ - **Strict once declared.** An action that declares at least one failure code
615
+ may only fail with declared codes; any other `fail` / `fail_with` raises
616
+ `Actionable::DefinitionError` at the call. Exempt: actions that declare none,
617
+ and the codes the gem emits itself, `Actionable::Action::GEM_FAILURE_CODES`
618
+ (`:invalid_input`, `:invalid_output`).
619
+
620
+ - **`allows_failure_code?(code)`** answers whether an action may fail with a
621
+ code under those rules.
622
+ - **RSpec support follows the declarations.** On an action that declares
623
+ failures, `allow_actionable_failure` / `stub_actionable_failure` build a
624
+ failure carrying the code's declared metadata (and message, when none is
625
+ given), and they and `and_fail` raise `ArgumentError` for a code the action
626
+ doesn't declare. `actionable_failure_result` takes `metadata:`.
627
+
628
+ - **`Actionable::RBS.generate`** emits a `failure_code` type alias for an
629
+ action that declares failures: the declared codes, then the codes the gem
630
+ emits itself.
631
+
632
+ ### Changed
633
+
634
+ - **A nested action's failure crosses intact.** `Steps::Action` used to re-fail
635
+ the parent with `fail!(code, message, **errors)`, which dropped anything else
636
+ on the child's failure and let a child error keyed like a keyword of an
637
+ overridden `fail` (pbx's `kind:`) bind to that keyword. The parent now adopts
638
+ the child's code, message, every error, its metadata and its result class
639
+ directly, with the parent's declared metadata for the code filling in keys
640
+ the child didn't set. A host override of `fail` no longer runs for a
641
+ propagated failure.
642
+ - `metadata` joins the reserved output field names, since an output field of
643
+ that name would be shadowed on a failure.
644
+ - `Failure#to_h` gains a `metadata` key (`{}` when the code isn't declared).
645
+
646
+ ## [1.2.2] - 2026-06-03
647
+
648
+ ### Tooling
649
+
650
+ - The repo is now a single-plugin Claude Code marketplace
651
+ (`.claude-plugin/{plugin.json,marketplace.json}`), so the bundled skill installs
652
+ via `/plugin marketplace add Paymentbox-com/actionable` +
653
+ `/plugin install actionable@actionable` (the skill still ships in the gem for a
654
+ no-marketplace copy). Mirrors the field_struct agent-harness setup.
655
+
656
+ ## [1.2.1] - 2026-06-03
657
+
658
+ ### Tooling
659
+
660
+ - The `rake docs:check` commit guard (decision D19) now detects a `git commit`
661
+ invocation from the command itself rather than a prefix gate, so a compound
662
+ `git add -A && git commit …` can no longer slip past the pre-commit hook. No
663
+ library changes.
664
+
665
+ ## [1.2.0] - 2026-06-03
666
+
667
+ Phase-2 ergonomics and adapters (decision D19), from real-world use fitting
668
+ Actionable into a polymorphic ingestion endpoint. All additive and
669
+ backward-compatible.
670
+
671
+ ### Added
672
+
673
+ - **`fail_with(source, code: :invalid, message: nil)` / `fail_with!`** — record a
674
+ `Failure` that absorbs an arbitrary FieldStruct's validation errors mid-step
675
+ (the mechanism `:invalid_input` / `:invalid_output` use internally, now exposed
676
+ as a verb via the shared `Failure#absorb_errors_from`).
677
+ - **`Result#to_h`, `#status`, `#to_api_h(index:, id_field:)`** — plain-Hash and
678
+ HTTP/batch-element views of a result, so controllers don't hand-map the shape.
679
+ - **`Action.run_each(enumerable) { |item| run_args }`** — run the action once per
680
+ item, collecting a `BatchResult` (Enumerable) with `all_ok?` / `any_failure?` /
681
+ `partial?`, `successes` / `failures` / `skips`, and `to_api_h`. Items run
682
+ independently.
683
+ - **`input_for(:discriminator) { on value, Shape; default Shape }`** — pick the
684
+ input schema at run time from a discriminator value (typed polymorphic input);
685
+ an unmatched value with no `default` is `Failure(:invalid_input)`. Backed by the
686
+ shared `Actionable::ValueMatch` (also used by `case_step`).
687
+ - **`require 'actionable/job'`** — a framework-agnostic background-job helper:
688
+ `Actionable::Job.disposition(result)` maps a result to `:ack` / `:retry` /
689
+ `:discard`, and `Actionable::Job::Mixin` (included into a Sidekiq worker or
690
+ ActiveJob job) runs an action in `perform` and raises `RetryableFailure` for a
691
+ retryable failure.
692
+
693
+ ### Changed
694
+
695
+ - **`skip` / `skip!` accept `**output` kwargs** (symmetric with `succeed`),
696
+ captured best-effort and never validated — so an idempotent hit can return the
697
+ existing record's data.
698
+ - **`measure :sampled, rate:`** — a third measurement mode recording history on a
699
+ fraction of runs (rate between 0 and 1) for low-overhead production
700
+ observability, alongside `:all` and `:none`.
701
+
702
+ ## [1.1.0] - 2026-06-03
703
+
704
+ Developer-experience improvements (decision D18): fail loudly on misconfiguration
705
+ and make actions self-describing.
706
+
707
+ ### Added
708
+
709
+ - `Actionable::Action.describe` — a structured summary (name, input/output field
710
+ metadata, ordered steps with type, hooks, measure mode, transaction config) so
711
+ humans and agents can understand an action without reading its source.
712
+ - `Actionable::DefinitionError` for misconfigured actions.
713
+
714
+ ### Changed
715
+
716
+ - A reserved `output` field name — one that shadows a result attribute
717
+ (`code`/`message`/`output`/`history`/`errors`) or a reserved instance variable
718
+ (`result`/`input`) — now raises `DefinitionError` when declared, instead of
719
+ silently returning `nil` or failing deep in the runner.
720
+ - A declared step whose method isn't implemented now raises a clear
721
+ `DefinitionError` (naming the action and method) at run start, instead of a
722
+ cryptic `NoMethodError`.
723
+
724
+ ## [1.0.0] - 2026-06-03
725
+
726
+ First feature-complete cut of the clean-slate rebuild.
727
+
728
+ ### Added
729
+
730
+ - **Core action model** — `Actionable::Action` with an ordered, inherited `step`
731
+ DSL and an `Actionable::Runner` that runs steps inside `catch(:actionable_halt)`,
732
+ auto-succeeding when no result was recorded.
733
+ - **Result value objects** — FieldStruct-backed `Success`, `Failure`, and
734
+ `Skipped`, with `success?`/`failure?`/`skipped?`/`ok?` predicates, structured
735
+ `errors`, and a compact, deterministic `inspect`.
736
+ - **Control flow** — `succeed` / `fail` / `skip` (record, continue) and
737
+ `succeed!` / `fail!` / `skip!` / `halt!` (record + halt) via `throw`/`catch`;
738
+ genuine exceptions propagate (no `rescue`).
739
+ - **The `:skip` outcome** (decision D17) — a third, first-class result for "nothing
740
+ to do": not a success, not a failure; `on_skip` hook; commits under
741
+ `transactional`; best-effort, unvalidated output.
742
+ - **Typed output** — `output do … end` schema; matching ivars captured and coerced,
743
+ `succeed` kwargs overlaid; only a strict success is validated (else
744
+ `Failure(:invalid_output)`); declared fields delegate off the result.
745
+ - **Typed input** — `input do … end` schema; `.run` coerces and validates kwargs,
746
+ exposes `input`, and short-circuits to `Failure(:invalid_input)` on bad input.
747
+ - **Step types** — method steps, nested action steps (`:input` / `:expose`,
748
+ failure propagation), and `case_step` branching (`==`, `Regexp`, Array
749
+ membership) with `:if` / `:unless` guards.
750
+ - **Lifecycle hooks** — `on_success` / `on_failure` / `on_skip` / `always`.
751
+ - **History & measurement** — `measure :all` records per-step section/name/timing/
752
+ code and nested history, cascading into nested actions; Oj serialization.
753
+ - **Registry** — `Actionable.registry` of all named actions.
754
+ - **Optional Rails adapter** (`require 'actionable/rails'`) — `transactional`
755
+ macro (rollback on failure or exception, commit on success/skip) and
756
+ `Actionable::ProxyValidator`.
757
+ - **Optional RSpec integration** (`require 'actionable/rspec'`) — the
758
+ `perform_actionable` matcher (`and_succeed` / `and_fail` / `and_skip` /
759
+ `and_raise` + block) and `allow_/stub_actionable_*` helpers.
760
+ - **Types & docs** — Sord/RBS/YARD toolchain (`sig/actionable.rbs`,
761
+ `rake sigs:* / docs:* / release:check`), `Actionable::RBS.generate` for user
762
+ actions, and doctested README/USAGE examples.
763
+
764
+ ## [0.1.0] - 2026-06-02
765
+
766
+ - Initial release