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.
- checksums.yaml +4 -4
- data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
- data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +92 -15
- data/.specify/templates/plan-template.md +4 -0
- data/.specify/templates/tasks-template.md +8 -1
- data/CHANGELOG.md +151 -0
- data/CLAUDE.md +2 -2
- data/README.md +149 -40
- data/lib/ruby_reactor/context.rb +9 -2
- data/lib/ruby_reactor/context_serializer.rb +13 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
- data/lib/ruby_reactor/dsl/lockable.rb +2 -2
- data/lib/ruby_reactor/dsl/reactor.rb +36 -18
- data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
- data/lib/ruby_reactor/dsl/template_helpers.rb +13 -5
- data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
- data/lib/ruby_reactor/executor/result_handler.rb +44 -13
- data/lib/ruby_reactor/executor/retry_manager.rb +6 -2
- data/lib/ruby_reactor/executor/step_executor.rb +27 -14
- data/lib/ruby_reactor/executor.rb +20 -15
- data/lib/ruby_reactor/map/element_executor.rb +7 -1
- data/lib/ruby_reactor/map/helpers.rb +9 -7
- data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
- data/lib/ruby_reactor/map/sweeper.rb +1 -1
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
- data/lib/ruby_reactor/open_telemetry.rb +7 -4
- data/lib/ruby_reactor/ordered_lock.rb +3 -3
- data/lib/ruby_reactor/reactor.rb +9 -12
- data/lib/ruby_reactor/rspec/matchers.rb +64 -17
- data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
- data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
- data/lib/ruby_reactor/step/compose_step.rb +56 -75
- data/lib/ruby_reactor/step/input_contract.rb +128 -0
- data/lib/ruby_reactor/step/map_step.rb +178 -215
- data/lib/ruby_reactor/step.rb +119 -18
- data/lib/ruby_reactor/step_signals.rb +37 -0
- data/lib/ruby_reactor/step_worker.rb +25 -10
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
- data/lib/ruby_reactor/template/result.rb +9 -2
- data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +23 -6
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor.rb +61 -9
- data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
- data/specs/002-step-input-contracts/data-model.md +115 -0
- data/specs/002-step-input-contracts/plan.md +165 -0
- data/specs/002-step-input-contracts/quickstart.md +170 -0
- data/specs/002-step-input-contracts/research.md +233 -0
- data/specs/002-step-input-contracts/spec.md +359 -0
- data/specs/002-step-input-contracts/tasks.md +367 -0
- data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
- data/specs/004-inheritable-step-class/data-model.md +116 -0
- data/specs/004-inheritable-step-class/plan.md +174 -0
- data/specs/004-inheritable-step-class/quickstart.md +112 -0
- data/specs/004-inheritable-step-class/research.md +308 -0
- data/specs/004-inheritable-step-class/spec.md +316 -0
- data/specs/004-inheritable-step-class/tasks.md +258 -0
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
- data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
- data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
- data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
- data/specs/deferred-003-step-lock-declarations/research.md +196 -0
- data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
- data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
- data/specs/possible_feature.md +22 -0
- metadata +34 -11
- data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
- data/specs/001-background-async-steps/checklists/requirements.md +0 -39
- data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
- data/specs/001-background-async-steps/data-model.md +0 -117
- data/specs/001-background-async-steps/plan.md +0 -168
- data/specs/001-background-async-steps/quickstart.md +0 -102
- data/specs/001-background-async-steps/research.md +0 -150
- data/specs/001-background-async-steps/spec.md +0 -146
- 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.
|