ruby_reactor 0.7.0 → 0.8.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
  3. data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
  4. data/.release-please-manifest.json +1 -1
  5. data/.specify/feature.json +1 -1
  6. data/.specify/memory/constitution.md +92 -15
  7. data/.specify/templates/plan-template.md +4 -0
  8. data/.specify/templates/tasks-template.md +8 -1
  9. data/CHANGELOG.md +151 -0
  10. data/CLAUDE.md +2 -2
  11. data/README.md +149 -40
  12. data/lib/ruby_reactor/context.rb +9 -2
  13. data/lib/ruby_reactor/context_serializer.rb +13 -0
  14. data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
  15. data/lib/ruby_reactor/dsl/lockable.rb +2 -2
  16. data/lib/ruby_reactor/dsl/reactor.rb +36 -18
  17. data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
  18. data/lib/ruby_reactor/dsl/template_helpers.rb +13 -5
  19. data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
  20. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  21. data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
  22. data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
  23. data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
  24. data/lib/ruby_reactor/executor/result_handler.rb +44 -13
  25. data/lib/ruby_reactor/executor/retry_manager.rb +6 -2
  26. data/lib/ruby_reactor/executor/step_executor.rb +27 -14
  27. data/lib/ruby_reactor/executor.rb +20 -15
  28. data/lib/ruby_reactor/map/element_executor.rb +7 -1
  29. data/lib/ruby_reactor/map/helpers.rb +9 -7
  30. data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
  31. data/lib/ruby_reactor/map/sweeper.rb +1 -1
  32. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
  33. data/lib/ruby_reactor/open_telemetry.rb +7 -4
  34. data/lib/ruby_reactor/ordered_lock.rb +3 -3
  35. data/lib/ruby_reactor/reactor.rb +9 -12
  36. data/lib/ruby_reactor/rspec/matchers.rb +64 -17
  37. data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
  38. data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
  39. data/lib/ruby_reactor/step/compose_step.rb +56 -75
  40. data/lib/ruby_reactor/step/input_contract.rb +128 -0
  41. data/lib/ruby_reactor/step/map_step.rb +178 -215
  42. data/lib/ruby_reactor/step.rb +119 -18
  43. data/lib/ruby_reactor/step_signals.rb +37 -0
  44. data/lib/ruby_reactor/step_worker.rb +25 -10
  45. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  46. data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
  47. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
  48. data/lib/ruby_reactor/template/result.rb +9 -2
  49. data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
  50. data/lib/ruby_reactor/version.rb +1 -1
  51. data/lib/ruby_reactor/web/api.rb +23 -6
  52. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
  53. data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
  54. data/lib/ruby_reactor/web/public/index.html +2 -2
  55. data/lib/ruby_reactor.rb +61 -9
  56. data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
  57. data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
  58. data/specs/002-step-input-contracts/data-model.md +115 -0
  59. data/specs/002-step-input-contracts/plan.md +165 -0
  60. data/specs/002-step-input-contracts/quickstart.md +170 -0
  61. data/specs/002-step-input-contracts/research.md +233 -0
  62. data/specs/002-step-input-contracts/spec.md +359 -0
  63. data/specs/002-step-input-contracts/tasks.md +367 -0
  64. data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
  65. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
  66. data/specs/004-inheritable-step-class/data-model.md +116 -0
  67. data/specs/004-inheritable-step-class/plan.md +174 -0
  68. data/specs/004-inheritable-step-class/quickstart.md +112 -0
  69. data/specs/004-inheritable-step-class/research.md +308 -0
  70. data/specs/004-inheritable-step-class/spec.md +316 -0
  71. data/specs/004-inheritable-step-class/tasks.md +258 -0
  72. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
  73. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
  74. data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
  75. data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
  76. data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
  77. data/specs/deferred-003-step-lock-declarations/research.md +196 -0
  78. data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
  79. data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
  80. data/specs/possible_feature.md +22 -0
  81. metadata +34 -11
  82. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
  83. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
  84. data/specs/001-background-async-steps/checklists/requirements.md +0 -39
  85. data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
  86. data/specs/001-background-async-steps/data-model.md +0 -117
  87. data/specs/001-background-async-steps/plan.md +0 -168
  88. data/specs/001-background-async-steps/quickstart.md +0 -102
  89. data/specs/001-background-async-steps/research.md +0 -150
  90. data/specs/001-background-async-steps/spec.md +0 -146
  91. data/specs/001-background-async-steps/tasks.md +0 -271
@@ -0,0 +1,174 @@
1
+ # Implementation Plan: Inheritable Step Class
2
+
3
+ **Branch**: `004-inheritable-step-class` | **Date**: 2026-09-11 | **Spec**: [spec.md](./spec.md)
4
+
5
+ **Input**: Feature specification from `/specs/004-inheritable-step-class/spec.md`
6
+
7
+ **Note**: This template is filled in by the `/speckit-plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
8
+
9
+ ## Summary
10
+
11
+ Replace the mixin-based step authoring surface (`include RubyReactor::Step` on a plain
12
+ class + `def self.run(args, context)`, with input enforcement hacked in via
13
+ `singleton_class.prepend`) with a single inheritable base class, `RubyReactor::Step`. A
14
+ step author subclasses it, declares `input`/`validate_inputs` on the class exactly as
15
+ today, and writes `run`/`undo`/`compensate` as **instance** methods that read validated
16
+ inputs and the context through accessors. The base class's class-level `run` (aliased
17
+ `call`), `undo`, and `compensate` become the library's one entry point per lifecycle
18
+ action: build a fresh instance, enforce the input contract, invoke the instance method,
19
+ and translate any `StepSignals` throw into the matching result wrapper — all as ordinary,
20
+ readable method calls instead of a prepended module. Owning the signal catch in the base
21
+ class also closes a latent bug: `StepWorker` has no catch today, so a `fail!` inside a
22
+ class step run by `async_step`/`background` currently surfaces as an `UncaughtThrowError`
23
+ (research.md D4). No dual authoring style, no backward-compatibility shim: the mixin form
24
+ is deleted, and every internal step (`ComposeStep`, `MapStep`, `AsyncReactorStep`), every
25
+ demo step, and every spec step migrates to the new class in this change.
26
+
27
+ ## Technical Context
28
+
29
+ **Language/Version**: Ruby >= 3.0.0 (gemspec `required_ruby_version`, unchanged)
30
+
31
+ **Primary Dependencies**: `dry-validation` ~> 1.10 (existing, unchanged — input contract
32
+ enforcement already built on it via `RubyReactor::Validation`); no new runtime dependency
33
+
34
+ **Storage**: N/A for this feature — no schema/persistence format changes. Serialization of
35
+ step arguments (`ContextSerializer`) is unaffected because it operates on plain
36
+ hashes/context, not on step instances.
37
+
38
+ **Testing**: RSpec (existing `spec/` suite + `demo_app/spec/reactors/` per Constitution
39
+ Principle VI); Redis required and reachable per Constitution Principle III — no test
40
+ strategy change, only the step classes under test change shape.
41
+
42
+ **Target Platform**: Ruby gem, consumed by Rails/plain-Ruby host applications; no
43
+ platform-specific change.
44
+
45
+ **Project Type**: Single library project (gem) — `lib/ruby_reactor/`, `spec/`, plus the
46
+ `demo_app/` Rails integration example mandated by Principle VI.
47
+
48
+ **Performance Goals**: No new performance target. One additional object allocation per
49
+ step invocation (the step instance, replacing the singleton-class call) is expected to be
50
+ negligible relative to existing Redis I/O per step; this plan carries no dedicated
51
+ benchmark task because the spec sets no throughput/latency success criterion.
52
+
53
+ **Constraints**: Every existing execution path (synchronous, async worker, retry,
54
+ compensation/undo, compose, map, RSpec test-subject interception) MUST keep producing
55
+ identical outcomes (spec User Story 2); the base class MUST NOT intercept or wrap
56
+ author-defined methods (spec FR-011); no compatibility shim (spec FR-012, project
57
+ IMPORTANT note — no production usage yet).
58
+
59
+ **Scale/Scope**: Touches one core file (`lib/ruby_reactor/step.rb`), the three built-in
60
+ step implementations, every step-shaped spec support file and example under `spec/` and
61
+ `demo_app/`, and the step-authoring sections of `README.md` and `./documentation`. No new
62
+ files outside `lib/ruby_reactor/step/` are structurally required; this is a targeted
63
+ internal refactor of an existing subsystem, not new surface area.
64
+
65
+ ## Constitution Check
66
+
67
+ *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
68
+
69
+ - **I. Gem-First Design** — PASS. `RubyReactor::Step` stays a plain Ruby class reachable
70
+ via `require "ruby_reactor"`, no host-application coupling introduced, no new optional
71
+ integration added.
72
+ - **II. Saga Pattern Integrity (NON-NEGOTIABLE)** — PASS, with explicit verification
73
+ required. Compensation/undo must keep working identically per-step (spec User Story 2,
74
+ scenarios 3–5); Phase 1 design and the resulting tasks MUST prove undo/compensate run on
75
+ a fresh instance from stored values only (no leftover run-time instance state crossing
76
+ process boundaries in async execution) — this is the highest-risk gate for this feature
77
+ and is called out explicitly in Complexity Tracking below.
78
+ - **III. Test-First with Real Infrastructure** — PASS. No test strategy change; the
79
+ existing RSpec + real-Redis suite is the verification mechanism, run before and after
80
+ migration to prove identical behavior (spec SC-003).
81
+ - **IV. Observability by Default** — PASS. Failure attribution (step name, redacted
82
+ inputs, failure reason) is unchanged in shape; `InputValidationError#step_name` and
83
+ `#step_arguments` continue to be set at the same point in the enforcement path, just
84
+ from inside the base class's class-level entry point instead of a prepended module.
85
+ - **V. Simplicity and Semantic Versioning (YAGNI)** — PASS, and this feature exists
86
+ *because of* this principle: it removes a `singleton_class.prepend` workaround. Per
87
+ spec Assumptions, dual authoring styles are explicitly rejected as unjustified
88
+ complexity. This is a breaking public-API change (mixin form removed with no
89
+ deprecation) — `CHANGELOG.md` MUST record it under a breaking-change heading per
90
+ Development Workflow. The gem is pre-1.0 (0.7.0) and `.release-please-config.json` sets
91
+ `bump-minor-pre-major: true`, so a `feat!:` commit yields **0.8.0**; release-please
92
+ owns the number, `version.rb` is never hand-edited.
93
+ - **VI. Demo-App Proof of Feature (NON-NEGOTIABLE)** — PASS, tracked as an explicit task.
94
+ All existing `demo_app/app/reactors/*.rb` steps using the mixin form MUST convert to the
95
+ new base class (7 occurrences in 3 files, plus 6 in 2 `demo_app/spec/` files —
96
+ research.md D8), and a new demo reactor
97
+ demonstrating the inheriting style end-to-end (success + failure/rollback path, plus a
98
+ brownfield service adapter per spec User Story 3) MUST be added with a matching rake
99
+ task and RSpec-matcher-only spec.
100
+ - [x] Documentation impact identified: `README.md` step-authoring sections (Quick Start /
101
+ core usage examples) and every file under `./documentation` that shows
102
+ `include RubyReactor::Step` (`getting_started.md`, `core_concepts.md`,
103
+ `composition.md`, `async_reactors.md`, `README.md` inside `documentation/`, and the
104
+ two files under `documentation/examples/`) MUST be updated to the inheriting form,
105
+ carried into `tasks.md` as required documentation tasks (Constitution Development
106
+ Workflow).
107
+
108
+ No unjustified violations. Proceeding to Phase 0.
109
+
110
+ ## Project Structure
111
+
112
+ ### Documentation (this feature)
113
+
114
+ ```text
115
+ specs/004-inheritable-step-class/
116
+ ├── plan.md # This file (/speckit-plan command output)
117
+ ├── research.md # Phase 0 output (/speckit-plan command)
118
+ ├── data-model.md # Phase 1 output (/speckit-plan command)
119
+ ├── quickstart.md # Phase 1 output (/speckit-plan command)
120
+ ├── contracts/ # Phase 1 output (/speckit-plan command)
121
+ │ └── step-lifecycle.md
122
+ └── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
123
+ ```
124
+
125
+ ### Source Code (repository root)
126
+
127
+ ```text
128
+ lib/ruby_reactor/
129
+ ├── step.rb # REWRITTEN: `class Step` replaces `module Step` (mixin)
130
+ ├── step_signals.rb # comment only: catch-site list now names RubyReactor::Step (D4)
131
+ ├── step_worker.rb # unchanged call site; gains correct signal handling via base class (D4)
132
+ ├── step/ # every file here flips `module Step` → `class Step` together (D9)
133
+ │ ├── input_contract.rb # namespace line only; contract logic unchanged
134
+ │ ├── compose_step.rb # MIGRATED: `class ComposeStep < RubyReactor::Step`; dead initialize deleted (D6)
135
+ │ ├── map_step.rb # MIGRATED: `class MapStep < RubyReactor::Step`; build_mapped_inputs/resolve_element stay class-level (D6)
136
+ │ └── async_reactor_step.rb # MIGRATED: `class AsyncReactorStep < RubyReactor::Step`
137
+ ├── executor/
138
+ │ ├── step_executor.rb # unchanged call sites (`impl.run(args, ctx)`)
139
+ │ └── compensation_manager.rb # unchanged call sites (`impl.compensate` / `impl.undo`)
140
+ ├── map/helpers.rb # unchanged; still calls MapStep.build_mapped_inputs (class-level)
141
+ ├── rspec/
142
+ │ └── test_subject.rb # unchanged call sites (`step_config.impl.run(args, ctx)`)
143
+ └── dsl/
144
+ └── template_helpers.rb # unchanged (inline block steps keep `include StepSignals`)
145
+
146
+ spec/
147
+ ├── ruby_reactor/step_contract_*.rb, step_signals_spec.rb, step/map_step_spec.rb, … # step classes rewritten to subclass form
148
+ ├── ruby_reactor/dsl/*step*_spec.rb # step classes rewritten to subclass form
149
+ └── support/reactors/*.rb # shared example steps rewritten to subclass form
150
+
151
+ demo_app/
152
+ ├── app/reactors/*.rb # 19 mixin usages migrated to subclass form
153
+ ├── app/reactors/<new>_reactor.rb # NEW demo reactor for inheriting style + brownfield adapter
154
+ ├── lib/tasks/demo_reactors.rake # NEW `demo:` task entry for the above
155
+ └── spec/reactors/<new>_reactor_spec.rb # NEW spec, shipped matchers only
156
+
157
+ README.md, documentation/*.md, CHANGELOG.md # updated per Constitution Development Workflow
158
+ ```
159
+
160
+ **Structure Decision**: Single-project Ruby gem layout (existing `lib/`/`spec/` +
161
+ `demo_app/` integration example). No new top-level directories. This is a rewrite of one
162
+ subsystem (`lib/ruby_reactor/step.rb` and its three built-in consumers) plus a
163
+ find-and-migrate pass over every place a step class is authored, not a new component.
164
+
165
+ ## Complexity Tracking
166
+
167
+ > **Fill ONLY if Constitution Check has violations that must be justified**
168
+
169
+ No constitution violations. This table instead records the one design risk Phase 0 must
170
+ close out before Phase 1 proceeds (Principle II gate above):
171
+
172
+ | Risk | Why it exists | How Phase 0/1 must resolve it |
173
+ |------|----------------|-------------------------------|
174
+ | Undo/compensate must not depend on run-time instance state | Async execution runs `run` in one worker process and `undo`/`compensate` in a possibly later, separate invocation; today's design (three independent `self.` methods) cannot leak state between them by construction, but instance methods on one class could tempt an author (or the built-in steps) into memoizing something in `run` and reading it in `undo` | `research.md` decides the instantiation contract (a **fresh instance per lifecycle action**, built only from the stored arguments/result/context — never a shared instance across run and undo); `data-model.md` documents accessors as read-only derived from constructor args, with no cross-action mutable state, and this is stated as a load-bearing rule in the base class's own comments (spec FR-009, FR-011) |
@@ -0,0 +1,112 @@
1
+ # Quickstart: Validating the Inheritable Step Class
2
+
3
+ Prerequisites: repo checked out on this feature branch, Redis reachable
4
+ (Constitution Principle III — `bundle exec rspec` fails fast at suite start if not),
5
+ `bundle install` run.
6
+
7
+ ## 1. Unit-level proof (User Story 1)
8
+
9
+ Write a throwaway subclass and invoke it directly — no reactor needed, matching how the
10
+ input-contracts feature was validated:
11
+
12
+ ```ruby
13
+ class QuickstartStep < RubyReactor::Step
14
+ input :amount, :integer, gt?: 0
15
+
16
+ def run
17
+ fail!("too much") if inputs[:amount] > 100
18
+ Success(charged: inputs[:amount])
19
+ end
20
+ end
21
+
22
+ ctx = RubyReactor::Context.new({})
23
+ QuickstartStep.run({ amount: 10 }, ctx) # => Success(charged: 10)
24
+ QuickstartStep.run({ amount: 200 }, ctx) # => Failure("too much")
25
+ QuickstartStep.run({ amount: -1 }, ctx) # raises RubyReactor::Error::InputValidationError, step_name "QuickstartStep"
26
+
27
+ # Through a reactor, that raised error becomes a Failure — confirm it is non-retryable
28
+ # (research.md D10): the same invalid amount would fail identically on a retry.
29
+ reactor = Class.new(RubyReactor::Reactor) { input :amount; step :charge, QuickstartStep }
30
+ result = reactor.run(amount: -1)
31
+ result.retryable? # => false
32
+ ```
33
+
34
+ Expected: first call succeeds with the body's value; second call returns the `fail!`
35
+ signal translated to a `Failure`, proving User Story 1 scenario 3; third call **raises**
36
+ (a direct call surfaces the validation error itself — the executor and worker are what
37
+ turn it into a rolled-back `Failure`) and never runs the body, proving scenario 2. See
38
+ [contracts/step-lifecycle.md](./contracts/step-lifecycle.md) for the full invocation
39
+ contract.
40
+
41
+ ## 1b. Worker-path signal proof (User Story 2 scenario 2, research.md D4)
42
+
43
+ The one behavior that *changes* on purpose: a class step calling `fail!` under
44
+ `async_step` / `background` must now produce the intended `Failure`, not a Failure wrapping
45
+ `UncaughtThrowError`. Write this spec first and watch it fail on the current code:
46
+
47
+ ```ruby
48
+ # spec/ruby_reactor/step_signals_worker_spec.rb (sketch)
49
+ class WorkerFailStep < RubyReactor::Step
50
+ def run = fail!("nope")
51
+ end
52
+ # reactor with `async_step :boom, WorkerFailStep`; run with drain_async_jobs
53
+ expect(result).to be_failure
54
+ expect(result.error).to eq("nope") # today: an UncaughtThrowError instance
55
+ ```
56
+
57
+ ## 2. Full-suite regression proof (User Story 2)
58
+
59
+ ```sh
60
+ bundle exec rspec
61
+ bundle exec rubocop
62
+ ```
63
+
64
+ Expected: 100% pass, identical to the pre-refactor baseline (spec SC-003). This exercises
65
+ every execution path — sync executor, async worker, retry, compensation/undo, compose,
66
+ map, and the RSpec test-subject interception surface — because those specs already cover
67
+ those paths against whatever step classes exist; after migration (research.md D6, D8)
68
+ they exercise the same paths against the new base class.
69
+
70
+ ## 3. Brownfield adapter proof (User Story 3)
71
+
72
+ ```ruby
73
+ # Pre-existing, untouched service:
74
+ class LegacyChargeService
75
+ def initialize(user_id) = @user_id = user_id
76
+ def call = @user_id.positive? ? OpenStruct.new(success?: true, id: 42) : OpenStruct.new(success?: false, error: "bad user")
77
+ end
78
+
79
+ class ChargeStep < RubyReactor::Step
80
+ input :user_id, :integer
81
+
82
+ def run
83
+ outcome = LegacyChargeService.new(inputs[:user_id]).call
84
+ outcome.success? ? Success(charge_id: outcome.id) : Failure(outcome.error)
85
+ end
86
+ end
87
+ ```
88
+
89
+ Expected: `LegacyChargeService` is never modified; `ChargeStep` is ≤10 lines (spec SC-004).
90
+
91
+ ## 4. Demo-app acceptance proof (User Story 5, Constitution Principle VI)
92
+
93
+ ```sh
94
+ docker compose up -d
95
+ docker compose run --rm demo-app bin/rails demo:inheritable_step # new task added by this feature
96
+ docker compose run --rm demo-app bundle exec rspec spec/reactors # demo_app has its own Gemfile/.rspec
97
+ ```
98
+
99
+ Expected: printed success and failure/rollback outcomes for the new demo reactor; the
100
+ full `demo_app/spec/reactors/` suite passes using only the shipped `lib/ruby_reactor/rspec.rb`
101
+ test surface (no hand-rolled scaffolding, per Principle VI). Run the demo specs from
102
+ inside `demo_app/` (or via the compose service as above), never from the gem root.
103
+
104
+ ## 5. Old authoring style is gone (User Story 5)
105
+
106
+ ```sh
107
+ grep -rnE "include RubyReactor::Step\b" lib spec demo_app README.md documentation
108
+ ```
109
+
110
+ Expected: zero matches (spec SC-002). The `\b` matters: without it the grep also hits
111
+ `include RubyReactor::StepSignals`, which is legitimate and stays. Any real match is
112
+ unfinished migration work, not an acceptable remainder.
@@ -0,0 +1,308 @@
1
+ # Phase 0 Research: Inheritable Step Class
2
+
3
+ No `NEEDS CLARIFICATION` markers remain in the Technical Context (see plan.md) or the
4
+ spec. This file records the design decisions Phase 1 depends on, found by reading the
5
+ current implementation rather than by external research — this is an internal refactor of
6
+ an existing subsystem, not a new-technology adoption.
7
+
8
+ ## D1: Class-level entry point shape
9
+
10
+ **Decision**: `RubyReactor::Step` exposes exactly three class methods —
11
+ `self.run(arguments, context)`, `self.undo(result, arguments, context)`,
12
+ `self.compensate(reason, arguments, context)` — matching the call sites that already
13
+ exist in `step_executor.rb` (`step_config.impl.run(arguments, @context)`) and
14
+ `compensation_manager.rb` (`step_config.impl.compensate(error, arguments, @context)` /
15
+ `step_config.impl.undo(result.value, arguments, @context)`). `self.call` is defined as an
16
+ alias of `self.run` (`singleton_class.alias_method :call, :run` in `inherited`, or simply
17
+ `def self.call(...) = run(...)`).
18
+
19
+ **Rationale**: Zero call-site changes needed in the executor, compensation manager, or
20
+ `rspec/test_subject.rb` — all three already call `impl.run` / `impl.compensate` /
21
+ `impl.undo` with exactly these signatures today (verified by reading
22
+ `lib/ruby_reactor/executor/step_executor.rb:357-361` and
23
+ `lib/ruby_reactor/executor/compensation_manager.rb:76-121`). The refactor changes what
24
+ happens *inside* those three class methods, not who calls them or with what arguments —
25
+ this is what keeps User Story 2 (every execution path unaffected) achievable without
26
+ touching the executor.
27
+
28
+ **Alternatives considered**:
29
+ - *A single `self.call(action, ...)` dispatcher* — rejected: would require changing three
30
+ call sites for no behavioral gain, and obscures the three distinct lifecycle actions
31
+ behind a string/symbol dispatch.
32
+ - *Keep `run`/`undo`/`compensate` as the only spellings, no `call` alias* — rejected: the
33
+ prompt explicitly asks for `class_alias :call, :run`-equivalent, and the demo/README
34
+ narrative style favors reading a step invocation as `MyStep.call(args, context)` in some
35
+ examples; costs nothing to alias.
36
+
37
+ ## D2: Fresh instance per lifecycle action (closes the Complexity Tracking risk)
38
+
39
+ **Decision**: Every class-level entry point builds its **own** instance —
40
+ `new(arguments, context)` for `run`, and a separate `new(arguments, context)` (plus the
41
+ one value that action alone receives — `result` for undo, `reason` for compensate) for
42
+ `undo`/`compensate`. No instance is cached or shared across actions on the class.
43
+
44
+ **Rationale**: Async execution already runs `run` and a later `undo`/`compensate` in
45
+ different invocations, potentially different processes (Sidekiq workers) — `step_worker.rb`
46
+ and `compensation_manager.rb` are separate call sites with no shared Ruby object between
47
+ them today (the mixin design made this true "for free" because there was never an
48
+ instance at all). Making the constructor the *only* place state enters an instance, and
49
+ giving each action its own instance, preserves that guarantee: an author cannot
50
+ accidentally memoize something in `run` and read it back in `undo`, because `undo`'s
51
+ instance never ran `run`. This directly satisfies spec FR-009 ("run on a fresh instance so
52
+ that behaviour is identical whether or not the run happened in the same process") and the
53
+ Edge Case "Run and rollback happen in different processes."
54
+
55
+ **Alternatives considered**:
56
+ - *One instance per class-level call, reused across a script/test that chains actions
57
+ manually* — rejected: no call site in the codebase does this, and it would create an
58
+ attractive nuisance (author writes code that "happens to work" only when run and undo
59
+ land in the same process during tests, then breaks in production async execution).
60
+ - *Memoized instance keyed by context id* — rejected: unnecessary caching layer, and the
61
+ existing `undo_stack`/compensation path already carries `arguments` and `result`/`reason`
62
+ forward explicitly (see `compensation_manager.rb`'s `undo_stack` entries), so there is
63
+ nothing an instance needs to remember between actions.
64
+
65
+ ## D3: Instance constructor signature and accessors
66
+
67
+ **Decision**: `initialize(inputs, context, result: nil, reason: nil)` stores everything
68
+ the instance can ever read. Reader names, decided here so tasks.md does not have to:
69
+
70
+ | Reader | Holds | Available in |
71
+ |---|---|---|
72
+ | `inputs` | the validated (or raw, when no contract) argument hash | run, undo, compensate |
73
+ | `context` | the workflow `RubyReactor::Context` | run, undo, compensate |
74
+ | `result` | the step's own stored result value | undo only (nil elsewhere) |
75
+ | `reason` | the failure that triggered rollback | compensate only (nil elsewhere) |
76
+
77
+ `undo` and `compensate` therefore take no parameters either — matching the description's
78
+ example shape (`def run` / `def undo` / `def compensate` with no explicit parameter list)
79
+ while still giving the instance everything the class-level entry point receives.
80
+
81
+ **Rationale**: The feature description's example shows zero-arg instance methods, which
82
+ only works if the instance already holds everything it needs from construction. This
83
+ matches the "form object" / "interactor" idiom common in the Rails codebases the gem
84
+ targets (brownfield compatibility, spec User Story 3). `inputs` (plural) is chosen over
85
+ `input`/`arguments` because it mirrors `context.inputs` on the reactor side and the inline
86
+ `inputs do ... end` contract block, and stays visibly distinct from `arguments`, the word
87
+ the executor uses everywhere for the *unvalidated* resolved hash — reviewers can tell at a
88
+ glance which side of validation a value is on.
89
+
90
+ **Alternatives considered**:
91
+ - *`input` (singular), mirroring the declaration keyword* — rejected: reads oddly for a
92
+ hash (`input[:amount]`), and the singular already means "one declaration" at class
93
+ level.
94
+ - *`arguments`, mirroring the executor* — rejected: same word for pre- and post-validation
95
+ values invites the exact confusion the contract exists to remove.
96
+
97
+ **Alternatives considered**:
98
+ - *Instance methods keep taking `(arguments, context)` as parameters, mirroring the old
99
+ class methods 1:1* — rejected: defeats the point of the refactor (spec explicitly wants
100
+ `def run` reading state via accessors, not parameters) and just relocates the same
101
+ shape one level down.
102
+
103
+ ## D4: Signal translation stays a `catch` at the class-level entry point
104
+
105
+ **Decision**: The class-level `run`/`undo`/`compensate` wrap the instance-method call in
106
+ `catch(StepSignals::TAG) { ... }` (for `run`, validation happens *before* entering the
107
+ catch, so an `InputValidationError` still raises rather than being confused with a
108
+ signal). The executor's and compensation manager's existing outer catches around
109
+ `step_config.impl.*` stay in place unchanged; the two levels nest safely because
110
+ `catch`/`throw` resolve at the innermost matching tag, and the outer ones still do the
111
+ real work for inline `run_block`/`compensate_block`/`undo_block` steps.
112
+
113
+ **This is not merely redundant — it fixes a latent bug.** `StepWorker#execute_step_body`
114
+ (`lib/ruby_reactor/step_worker.rb`) calls `step_config.impl.run(arguments, context)` with
115
+ **no** `catch(StepSignals::TAG)` at all (verified: the file contains no `catch`). Today a
116
+ `fail!`/`success!`/`skip!`/`halt!` inside a class step executed by the `async_step` /
117
+ `background` worker escapes as `UncaughtThrowError`, which is a `StandardError`, so the
118
+ worker's `rescue StandardError` reports a Failure wrapping the wrong error (and marks it
119
+ retryable). Moving the catch into the base class makes class steps behave identically on
120
+ the worker path with no worker change — this is exactly spec User Story 2 scenario 2.
121
+ Consequences carried into tasks.md:
122
+
123
+ - A red test first: a class step calling `fail!("x")` under `async_step` must yield
124
+ `Failure("x")`, not a Failure wrapping `UncaughtThrowError`.
125
+ - Inline `run_block` steps on the worker path remain uncaught. That is pre-existing, out
126
+ of scope (spec FR-013: inline blocks "keep working unchanged"), and gets a one-line note
127
+ in `CHANGELOG.md`'s known-issues or a follow-up issue — not silently ignored.
128
+ - The comment in `lib/ruby_reactor/step_signals.rb` listing the catch sites
129
+ (`step_executor.rb`, `compensation_manager.rb`) is stale; update it to name
130
+ `RubyReactor::Step`'s entry points as the catch site for class steps.
131
+
132
+ **Rationale**: Spec FR-007 requires signals to be "translated at the class-level entry
133
+ point," a property of `RubyReactor::Step`'s own three methods, not of any caller. Owning
134
+ the catch in the base class is what makes every current and future call site (executor,
135
+ worker, direct call in a unit test) behave the same without each remembering to wrap.
136
+
137
+ **Alternatives considered**:
138
+ - *Remove the executor-level catch since it becomes dead code for class-based steps* —
139
+ rejected: it is not dead for inline block steps, and removing working code the feature
140
+ doesn't require touching is the kind of speculative churn Principle V rules out.
141
+ - *Add a `catch` to the worker instead of the base class* — rejected: fixes one caller,
142
+ leaves the next one (a direct `MyStep.run` in a unit test, a future dispatcher) to
143
+ rediscover the bug; FR-007 puts the responsibility on the step class.
144
+
145
+ ## D5: Default `undo`/`compensate` and "must implement" error
146
+
147
+ **Decision**: The base class's own instance-level `undo`/`compensate` return
148
+ `RubyReactor.Skipped()` (today's default, moved from the `ClassMethods` module verbatim).
149
+ The base class's instance-level `run` raises `NotImplementedError` naming the subclass
150
+ (`"#{self.class} must implement #run"`), preserving today's message shape
151
+ (`"#{self} must implement .run method"`) with `.` replaced by `#` to reflect that it is
152
+ now an instance method.
153
+
154
+ **Rationale**: Directly satisfies spec FR-009 and FR-010; minimizes behavior change for
155
+ every subclass that already relies on the "compensation/undo default to skip" contract
156
+ (every demo reactor and most spec support reactors do not define both).
157
+
158
+ **Alternatives considered**: None — this is a direct behavior-preserving port.
159
+
160
+ ## D6: Migration scope for the three built-in steps
161
+
162
+ **Decision**: `ComposeStep`, `MapStep`, and `AsyncReactorStep` become
163
+ `class ComposeStep < RubyReactor::Step` etc., with their `self.run`/`self.compensate`/
164
+ `self.undo` bodies moved into instance `run`/`compensate`/`undo` methods that read
165
+ `inputs`/`context` (and `reason`/`result`) via the D3 accessors instead of method
166
+ parameters. Their `class << self; private; ...; end` helper methods (argument-building,
167
+ dispatch, deadlock detection, etc.) move to **private instance methods** — with two
168
+ exceptions that stay **public class methods** because they are called from outside the
169
+ step: `MapStep.build_mapped_inputs` and `MapStep.resolve_element`, used by
170
+ `lib/ruby_reactor/map/helpers.rb:32` for element workers. None of the three declare
171
+ `input`/`validate_inputs` today (they receive a pre-built `arguments` hash assembled by
172
+ the DSL layer, e.g. `arguments[:composed_reactor_class]`), so no input contract is added
173
+ for them — only the authoring shape changes.
174
+
175
+ `ComposeStep` additionally carries a dead `initialize(composed_reactor_class,
176
+ argument_mappings = {})` and two `attr_reader`s — nothing in `lib/`, `spec/`, or
177
+ `demo_app/` ever calls `ComposeStep.new` (verified by grep). It would shadow the base
178
+ class's `initialize(inputs, context, ...)`, so it is deleted, not migrated.
179
+
180
+ **Rationale**: Spec FR-014 requires this; these three are the only "library-internal
181
+ steps" that used the mixin form (verified: an anchored `grep -rn "include RubyReactor::Step$"`
182
+ across `lib/` hits exactly these three files — an unanchored grep also matches
183
+ `include RubyReactor::StepSignals` in `step.rb` and `dsl/template_helpers.rb`, which are
184
+ not migrations). Converting private class helpers to private instance methods is a
185
+ mechanical, behavior-preserving move — that logic only ever received `arguments`/`context`
186
+ as explicit parameters. `MapStep` is the largest diff (~300 lines) and should be its own
187
+ task with the existing `spec/ruby_reactor/step/map_step_spec.rb` and `spec/map/` suites
188
+ as the red/green gate.
189
+
190
+ **Alternatives considered**:
191
+ - *Leave the three built-in steps on a separate, lighter-weight internal mixin instead of
192
+ the public `RubyReactor::Step` base class* — rejected: spec FR-014 is explicit these
193
+ migrate too, and a second internal-only step-authoring mechanism reintroduces the exact
194
+ "two ways to build a step" variance the spec's Assumptions section rules out.
195
+
196
+ ## D7: Inline block steps (reactor DSL `step ... do |args, ctx| ... end`)
197
+
198
+ **Decision**: Untouched. Inline block steps are stored as `run_block`/`compensate_block`/
199
+ `undo_block` procs on `StepConfig` (see `dsl/step_builder.rb`) and invoked directly by the
200
+ executor/compensation manager, never through `RubyReactor::Step`. They already get
201
+ `StepSignals` via `include RubyReactor::StepSignals` in `dsl/template_helpers.rb`. Nothing
202
+ here changes their behavior or signature.
203
+
204
+ **Rationale**: Spec explicitly scopes inline blocks as "keep working unchanged" (FR-013,
205
+ Edge Cases). They are a different authoring surface (a block, not a class) and were never
206
+ affected by the `singleton_class.prepend` workaround this feature removes.
207
+
208
+ ## D8: Migration inventory (counts by anchored grep, for tasks.md sizing)
209
+
210
+ Pattern: `grep -rn "include RubyReactor::Step$"` (anchored — the unanchored form also
211
+ matches `StepSignals`). Counted 2026-09-11; `/speckit-tasks` re-runs it.
212
+
213
+ | Area | Files | Occurrences | Notes |
214
+ |---|---|---|---|
215
+ | `lib/ruby_reactor/step/` | 3 | 3 | the built-ins (D6) |
216
+ | `spec/` | 13 | 34 | `spec/support/reactors/*.rb` shared steps + per-spec inline classes |
217
+ | `demo_app/app/reactors/` | 3 | 7 | `validated_user_step.rb` (1), `user_etl_reactor.rb` (5), `reserve_inventory.rb` (1) |
218
+ | `demo_app/spec/` | 2 | 6 | |
219
+ | `README.md` | 1 | 5 | |
220
+ | `documentation/` | 7 | 28 | `getting_started`, `core_concepts`, `composition`, `async_reactors`, `README`, `examples/order_processing`, `examples/payment_processing` |
221
+
222
+ - No gem dependency changes, no `Gemfile`/`gemspec` edits required.
223
+ - Version bump: `.release-please-config.json` sets `bump-minor-pre-major: true`, so a
224
+ `feat!:` / `BREAKING CHANGE:` commit takes 0.7.0 → **0.8.0**, not 1.0.0. The
225
+ `CHANGELOG.md` entry still goes under a breaking-change heading with a before/after
226
+ example (spec FR-015); the number is release-please's business.
227
+
228
+ ## D9: `module Step` becomes `class Step` — every reopening must change together
229
+
230
+ **Decision**: All five files that currently open `module RubyReactor::Step` —
231
+ `step.rb`, `step/input_contract.rb`, `step/compose_step.rb`, `step/map_step.rb`,
232
+ `step/async_reactor_step.rb` — switch to `class Step` in the same commit. The nested
233
+ constants keep their names (`RubyReactor::Step::InputContract`,
234
+ `RubyReactor::Step::ComposeStep`, `RubyReactor::Step::MapStep`,
235
+ `RubyReactor::Step::AsyncReactorStep`), so the five `lib/` references
236
+ (`dsl/compose_builder.rb`, `dsl/map_builder.rb`, `dsl/async_reactor_builder.rb`,
237
+ `dsl/step_builder.rb`, `map/helpers.rb`) and two `spec/` references are untouched.
238
+
239
+ **Rationale**: Ruby raises `TypeError: Step is not a module` the moment a `module Step`
240
+ reopening loads after `class Step` (or the reverse), so a partial conversion cannot even
241
+ boot — this is a hard ordering constraint, not a style choice. Keeping the built-in steps
242
+ nested inside their own parent class (`class Step; class MapStep < Step`) is legal, a
243
+ little unusual to read, and the smallest diff; the three built-ins are internal plumbing
244
+ addressed by the DSL builders, never by users, so the namespace is not a public-API
245
+ concern.
246
+
247
+ **Alternatives considered**:
248
+ - *Move the built-ins to a sibling namespace (`RubyReactor::Steps::MapStep`)* — rejected
249
+ for this change: seven reference edits plus rename churn for zero user-visible benefit.
250
+ Worth revisiting only if a fourth built-in step appears (Principle V).
251
+
252
+ ## D10: Input-validation failures are non-retryable everywhere, enforced once
253
+
254
+ **Decision**: Give `Error::InputValidationError` its own `retryable?` method returning
255
+ `false`, mirroring the existing `Error::StepFailureError#retryable?` pattern
256
+ (`lib/ruby_reactor/error/step_failure_error.rb`).
257
+
258
+ **Rationale — a real, verified gap, not a hypothetical**: `RubyReactor::Failure.new`
259
+ already defaults `retryable` from the wrapped error when no explicit `retryable:`/`retry:`
260
+ kwarg is given (`lib/ruby_reactor.rb:164-168`:
261
+ `@error.respond_to?(:retryable?) ? @error.retryable? : true`). Three places build a
262
+ `Failure` from an `Error::InputValidationError`:
263
+
264
+ | Call site | Passes `retryable:` explicitly? | Result today |
265
+ |---|---|---|
266
+ | `StepWorker#execute_step_body` rescue (`step_worker.rb:128-133`, the `async_step`/`background` path) | Yes — `retryable: false` | Correct, and covered by a passing spec (`spec/ruby_reactor/step_contract_async_spec.rb:24-33,59`) |
267
+ | `Executor::ResultHandler#build_validation_failure` (`result_handler.rb:70-82`, the **synchronous** path — the default way almost every reactor runs) | No | `InputValidationError` has no `retryable?` today → defaults to `true` — **wrong**, no test catches it |
268
+ | `Step::ComposeStep#handle_execution_result` (`compose_step.rb`, re-wraps a composed child's failure as `RubyReactor.Failure(result.error)`) | No | Same default → `true` — **wrong**, a validation failure inside a `compose`d child currently looks retryable to the parent |
269
+
270
+ A single `retryable?` method on the error class fixes all three by construction: the two
271
+ call sites that pass nothing now inherit the correct answer from the error itself, and
272
+ the one call site that already passes `retryable: false` keeps doing so redundantly
273
+ (explicit always wins — no behavior change there, no need to touch that file). Any future
274
+ call site that wraps this error class in a `Failure` without thinking about retryability
275
+ gets the right answer for free — this is the same reasoning as D1/D4: put the guarantee
276
+ on the one object that knows it's true, not on every caller.
277
+
278
+ **Verified safe**: no existing spec asserts `retryable?` true for an
279
+ `InputValidationError`-derived `Failure`; the one comment that looks related
280
+ (`spec/ruby_reactor/interrupt_spec.rb:62`, "`Should raise validation error (retryable)`")
281
+ is about a raised exception on interrupt resume being retriable by resubmitting a
282
+ corrected payload — a different mechanism, not a `Failure#retryable?` flag — and is
283
+ unaffected (confirmed next paragraph).
284
+
285
+ **Explicitly out of scope**: `Reactor#continue`'s own rescue at `reactor.rb:190-193`
286
+ (interrupt/resume payload validation) builds `RubyReactor::Failure(e.message, ...)` from
287
+ the error's **message string**, not the error object, so this fix does not reach it and
288
+ `String#respond_to?(:retryable?)` stays `false` → still defaults to `true` there. That is
289
+ a distinct, intentional mechanism (a human resubmits a corrected payload; "retryable" is
290
+ the right word for it) unrelated to a step's own input contract, and outside what "step
291
+ validations" in this feature's scope means. Not changed here.
292
+
293
+ **Alternatives considered**:
294
+ - *Add `retryable: false` explicitly at `build_validation_failure` and
295
+ `handle_execution_result`* — rejected: fixes the two known sites but not the next one
296
+ (Principle V: fix the property once, not the symptom twice); also a currently-passing
297
+ spec fixture (`spec/ruby_reactor/step_contract_enforcement_spec.rb`) constructs
298
+ `InputValidationError` directly in a few places without going through either helper, and
299
+ a class-level `retryable?` is the only fix that covers those uses too, e.g. a future
300
+ direct `RubyReactor::Failure(e)` written by a test helper.
301
+ - *Check `error.is_a?(Error::InputValidationError)` inside `RubyReactor::Failure#initialize`
302
+ itself* — rejected: `lib/ruby_reactor.rb` already uses the `respond_to?(:retryable?)`
303
+ protocol precisely so it never needs to know about individual error subclasses; adding a
304
+ type check there would be a regression of that existing design, not a fix.
305
+
306
+ ## Outcome
307
+
308
+ All unknowns resolved. No `NEEDS CLARIFICATION` remains. Proceeding to Phase 1.