ruby_reactor 0.7.1 → 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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +26 -16
  5. data/.specify/templates/plan-template.md +4 -0
  6. data/.specify/templates/tasks-template.md +1 -1
  7. data/CHANGELOG.md +140 -0
  8. data/CLAUDE.md +1 -1
  9. data/README.md +125 -31
  10. data/lib/ruby_reactor/context.rb +2 -2
  11. data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
  12. data/lib/ruby_reactor/dsl/reactor.rb +36 -18
  13. data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
  14. data/lib/ruby_reactor/dsl/template_helpers.rb +2 -2
  15. data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
  16. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  17. data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
  18. data/lib/ruby_reactor/executor/result_handler.rb +9 -3
  19. data/lib/ruby_reactor/executor/retry_manager.rb +2 -1
  20. data/lib/ruby_reactor/executor/step_executor.rb +9 -2
  21. data/lib/ruby_reactor/executor.rb +3 -0
  22. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
  23. data/lib/ruby_reactor/reactor.rb +9 -12
  24. data/lib/ruby_reactor/rspec/matchers.rb +3 -6
  25. data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
  26. data/lib/ruby_reactor/step/compose_step.rb +56 -75
  27. data/lib/ruby_reactor/step/input_contract.rb +128 -0
  28. data/lib/ruby_reactor/step/map_step.rb +177 -218
  29. data/lib/ruby_reactor/step.rb +116 -21
  30. data/lib/ruby_reactor/step_signals.rb +6 -2
  31. data/lib/ruby_reactor/step_worker.rb +25 -10
  32. data/lib/ruby_reactor/template/result.rb +9 -2
  33. data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
  34. data/lib/ruby_reactor/version.rb +1 -1
  35. data/lib/ruby_reactor.rb +5 -2
  36. data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
  37. data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
  38. data/specs/002-step-input-contracts/data-model.md +115 -0
  39. data/specs/002-step-input-contracts/plan.md +165 -0
  40. data/specs/002-step-input-contracts/quickstart.md +170 -0
  41. data/specs/002-step-input-contracts/research.md +233 -0
  42. data/specs/002-step-input-contracts/spec.md +359 -0
  43. data/specs/002-step-input-contracts/tasks.md +367 -0
  44. data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
  45. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
  46. data/specs/004-inheritable-step-class/data-model.md +116 -0
  47. data/specs/004-inheritable-step-class/plan.md +174 -0
  48. data/specs/004-inheritable-step-class/quickstart.md +112 -0
  49. data/specs/004-inheritable-step-class/research.md +308 -0
  50. data/specs/004-inheritable-step-class/spec.md +316 -0
  51. data/specs/004-inheritable-step-class/tasks.md +258 -0
  52. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
  53. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
  54. data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
  55. data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
  56. data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
  57. data/specs/deferred-003-step-lock-declarations/research.md +196 -0
  58. data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
  59. data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
  60. data/specs/possible_feature.md +22 -0
  61. metadata +28 -1
@@ -0,0 +1,367 @@
1
+ ---
2
+
3
+ description: "Task list for Step Input Contracts"
4
+ ---
5
+
6
+ # Tasks: Step Input Contracts
7
+
8
+ **Input**: Design documents from `specs/002-step-input-contracts/`
9
+
10
+ **Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/dsl-surface.md, quickstart.md
11
+
12
+ **Tests**: REQUIRED. Constitution III makes test-first mandatory. Write each story's specs, watch
13
+ them fail, then implement. Real Redis always (`docker compose up -d redis-test`). The async worker
14
+ claim uses the live lane (`for_each_real_async_backend` from `spec/support/real_async_backend.rb`).
15
+ `Sidekiq::Testing.inline!` is not allowed in these specs.
16
+
17
+ **Organization**: Grouped by user story. The plan's Phase 2 outline maps as: plan 1 → Phase 2,
18
+ plans 2–3 → US1, plan 4 → US2, plan 5 → US3, plan 6 → US4, plan 7 → US5, plan 8 → Polish.
19
+
20
+ ## Format: `[ID] [P?] [Story] Description`
21
+
22
+ - **[P]**: Can run in parallel (different files, no dependency on an incomplete task)
23
+ - **[Story]**: User story the task belongs to (US1–US5)
24
+
25
+ ## Findings from the code that the tasks below encode
26
+
27
+ Read these before starting. Each was found in the current code and is not in plan.md, or it
28
+ corrects plan.md.
29
+
30
+ 1. **Zeitwerk naming.** `Zeitwerk::Loader.for_gem` (`lib/ruby_reactor.rb:37`) maps
31
+ `utils/fetch_indifferent.rb` to the constant `RubyReactor::Utils::FetchIndifferent`. research D8's
32
+ `RubyReactor::Utils.fetch_indifferent(...)` module function would fail eager loading. Use
33
+ `RubyReactor::Utils::FetchIndifferent.call(hash, key)`, which matches
34
+ `Utils::BacktraceLocation.parse` (T003).
35
+ 2. **A subclass's `def self.run` skips a wrapper prepended onto its parent.** The singleton
36
+ ancestry of `class Child < ParentStep` is `[Child.singleton, <wrapper>, ParentStep.singleton, ...]`,
37
+ so `Child.run` runs before the parent's prepended wrapper. `Step::ClassMethods#inherited` has to
38
+ prepend the wrapper onto every subclass's singleton class too (T010). A child that calls `super`
39
+ validates twice. That is harmless: validation is idempotent and defaults are already applied.
40
+ 3. **`args_validator` cannot apply defaults.** `validate_step_arguments` (`step_executor.rb:312`)
41
+ discards the validated value. Switching it to use `result.to_h` would change behavior for
42
+ existing validators, because `Dry::Schema.Params` coerces values and drops undeclared keys. So
43
+ inline contracts do **not** compile to `args_validator`, which supersedes research D4. They are
44
+ enforced by the same `InputContract#enforce!` call the class wrapper uses, at the two run-block
45
+ call sites (`StepExecutor#run_step_implementation`, `StepWorker#execute_step_body`). That leaves
46
+ one enforcement method called from three places instead of two mechanisms, and SC-005
47
+ equivalence holds by construction (T020, T023).
48
+ 4. **The step receives the resolved values, never the schema's coerced output.** This matches
49
+ today's `validate_step_arguments`. `enforce!` returns `args` merged with defaults, not
50
+ `result.to_h`.
51
+ 5. **The worker process needs the inferred wiring too.** D7 appends inferred wirings to
52
+ `StepConfig#arguments` inside `validate_definition!`. `StepWorker` runs in a fresh process and
53
+ resolves arguments directly (`step_worker.rb:150`) without building an `Executor`. If nothing in
54
+ that process calls `validate_definition!`, a name-resolved class step receives `{}` in the
55
+ worker and fails with "is missing". `validate_definition!` is therefore called from
56
+ `Reactor#run`, `Executor#initialize` (resume, map, compose, background workers), and
57
+ `StepWorker#perform_unit` (T028). It is memoized, so the extra calls cost nothing.
58
+ 6. **Step attribution is overwritten, not `||=`.** For direct invocation (FR-022), the wrapper
59
+ stamps `step_name` with the step class's name. Inside a reactor, the executor must then
60
+ **overwrite** it with the reactor's step name (`:charge`, not `"ChargeStep"`). research D3's
61
+ `e.step_name ||= step_config.name` would keep the class name (T012).
62
+ 7. **`Failure` never redacts `step_arguments`.** `Failure#append_step_arguments`
63
+ (`lib/ruby_reactor.rb:274`) prints every value, and `run_step_implementation` writes raw
64
+ arguments into the execution trace, which the dashboard reads. FR-015 is met by masking at the
65
+ source: `enforce!` puts a redacted copy on `error.step_arguments`, and the trace entry uses
66
+ `input_contract.redact(arguments)` (T008, T012).
67
+ 8. **Validation failures in the worker are retried and lose their shape.**
68
+ `StepWorker#execute_step_body` wraps every exception in a generic `Failure(e)`, which is
69
+ retryable by default, so `retry?` would loop. The new `InputValidationError` branch must pass
70
+ `retryable: false` and `validation_errors:` (T015).
71
+ 9. **Fixture files load with the whole suite.** `spec_helper.rb:56` requires
72
+ `spec/support/**/*.rb`. A fixture that calls `input` before the DSL exists breaks loading for
73
+ every spec. Fixtures that use the new DSL are created only after the DSL task they depend on
74
+ (T013 after T009, T022 after T019). Everything else is defined inline in the spec with
75
+ `stub_const`/`Class.new`.
76
+ 10. **Class steps get no implicit inputs today** (research Finding 1). Until US4 lands, US1–US3
77
+ specs must wire every input with an explicit `argument :x, input(:x)`.
78
+ 11. **`InterruptBuilder < StepBuilder`** (`dsl/interrupt_builder.rb:5`). Interrupt steps would
79
+ inherit `inputs`, and `InterruptBuilder#build` would silently drop the contract. Override it to
80
+ raise (T021).
81
+ 12. **Existing deprecation idiom:** `warn "[RubyReactor] DEPRECATION: ..."` guarded by an ivar
82
+ (`dsl/reactor.rb:107`, `dsl/interrupt_builder.rb:40`). Don't use `warn(category: :deprecated)`:
83
+ Ruby hides that category by default, which would make the FR-011 notice invisible (T030).
84
+
85
+ ## Shared names used across tasks
86
+
87
+ - `RubyReactor::Step::InputContract` in `lib/ruby_reactor/step/input_contract.rb`. Includes
88
+ `Dsl::ValidationHelpers`, so step classes don't gain those helper methods.
89
+ - `InputContract::Declaration = Struct.new(:name, :type, :optional, :default, :redact, :predicates,
90
+ :macro_block, :schema, :validator, keyword_init: true)`. Use `Struct`, not `Data`: Ruby >= 3.0.
91
+ - `InputContract` API: `#input(name, type = nil, optional: false, default: nil, redact: false,
92
+ validate: nil, **predicates, &block)`, `#validate_inputs(schema = nil, &block)`, `#declarations`
93
+ (ordered `Hash{Symbol => Declaration}`), `#declares?(name)`, `#required_names`, `#optional_names`,
94
+ `#defaults`, `#redacted_names`, `#empty?`, `#merge(child)` (returns a new contract, used for
95
+ inheritance), `#redact(args)` (copy with redacted names → `"[REDACTED]"`), and
96
+ `#enforce!(args)`. `enforce!` returns `args` plus defaults, or raises
97
+ `Error::InputValidationError` with `field_errors`, `step_arguments = redact(args)`, and
98
+ `step_name` left for the caller to stamp.
99
+ - Step class API: `input`, `validate_inputs`, `input_contract`, `declared_inputs`,
100
+ `required_input_names`, `declares_inputs?`. `RubyReactor::Step::InputEnforcement` is the module
101
+ prepended onto the singleton class.
102
+ - `StepConfig#inline_contract` (from `inputs do`, nil otherwise) and `StepConfig#input_contract`
103
+ (`inline_contract` if present, else `impl.input_contract` when `impl` declares inputs, else nil).
104
+ - `StepConfig#arguments` entries gain `origin:` (`:explicit` from `argument`, `:inferred` from
105
+ name-based resolution).
106
+ - `RubyReactor::Utils::FetchIndifferent.call(hash, key)` returns
107
+ `hash.key?(key.to_sym) ? hash[key.to_sym] : hash[key.to_s]`.
108
+ - Fixture file for the live worker: `spec/support/reactors/step_contract_reactors.rb`.
109
+
110
+ ---
111
+
112
+ ## Phase 1: Setup (Shared Infrastructure)
113
+
114
+ **Purpose**: Record where the suite stands before anything changes
115
+
116
+ - [X] T001 Run `docker compose up -d redis-test`, then `bundle exec rspec` and `bundle exec rubocop` on the untouched branch. Save the example/failure/pending counts and the rubocop offense count to `specs/002-step-input-contracts/baseline.txt`. SC-006 is checked against this file at the end (T031, T041)
117
+
118
+ ---
119
+
120
+ ## Phase 2: Foundational (Blocking Prerequisites)
121
+
122
+ **Purpose**: Presence-correct value resolution (FR-023) and one shared validator dispatch. US1 AS6
123
+ and every default depend on the first; `InputContract` depends on the second.
124
+
125
+ **⚠️ CRITICAL**: No user story work can begin until this phase is complete
126
+
127
+ - [X] T002 [P] Write `spec/ruby_reactor/falsey_input_resolution_spec.rb` (red). Cases, each asserting the step body receives exactly `false`, not nil: (a) reactor input `flag: false` → inline step `argument :flag, input(:flag)`; (b) same reactor input → class step `ReceivesFlag` (plain `include RubyReactor::Step`, no contract, defined with `stub_const`); (c) prior step returns `false`, next step wires `argument :flag, result(:first)`; (d) nested paths `input(:config, :notify)`, `input(:config, [:a, :notify])`, `result(:first, :flag)` with a `false` leaf; (e) `RubyReactor::Context#get_input` and `#get_result` for a symbol-keyed `false` and a string-keyed `false`; (f) the private `Template::Result#fetch` (call via `send`) on `{ success: false }` and `{ "success" => false }`; (g) `ContextSerializer.deserialize(ContextSerializer.serialize(ctx)).get_input(:flag) == false` for the serialization round trip. Also assert `0`, `""`, `[]` survive in (a)
128
+ - [X] T003 [P] Create `lib/ruby_reactor/utils/fetch_indifferent.rb` defining `RubyReactor::Utils::FetchIndifferent` with `def self.call(hash, key) = hash.key?(key.to_sym) ? hash[key.to_sym] : hash[key.to_s]`. The class name follows Zeitwerk (Finding 1). No spec of its own: T002 covers it
129
+ - [X] T004 Replace the `a || b` lookups with `Utils::FetchIndifferent.call` in `RubyReactor::Context#get_input` (`lib/ruby_reactor/context.rb:67`), `#get_result` (`context.rb:79`), and `Template::Result#fetch` (`lib/ruby_reactor/template/result.rb:177`). Keep `return nil if value.nil?` as is. T002 goes green and the rest of the suite stays at baseline (depends on T003)
130
+ - [X] T005 [P] Move the form dispatch out of `Dsl::Reactor::ClassMethods#build_input_validator_for` (`lib/ruby_reactor/dsl/reactor.rb:91`) into a public `build_declaration_validator(name, type, optional, validate, predicates, &block)` in `lib/ruby_reactor/dsl/validation_helpers.rb`. The reactor method keeps only its reactor-specific part, `warn_deprecated_input_block` when `block&.arity&.zero?`, and delegates the rest. This is a pure refactor: `spec/ruby_reactor/validations_spec.rb` must stay green unchanged (FR-009)
131
+
132
+ **Checkpoint**: `false` reaches steps intact; one validator dispatch serves reactor inputs and, next, step contracts
133
+
134
+ ---
135
+
136
+ ## Phase 3: User Story 1 - A step class declares its own input contract (Priority: P1) 🎯 MVP
137
+
138
+ **Goal**: `input :x, :type, **rules` inside a step class, enforced before `run` on every
139
+ execution path (inline, retry, map, `background`, `async_step` worker, direct call). Failures use
140
+ the existing `InputValidationError` → rollback → `build_validation_failure` protocol.
141
+
142
+ **Independent Test**: A step class with a typed, bounded contract, run from a minimal reactor
143
+ that wires inputs with explicit `argument` lines and declares no rules. Conforming values succeed.
144
+ Violating values fail with `validation_errors` and `step_name`, and the body never runs.
145
+
146
+ ### Tests for User Story 1 ⚠️
147
+
148
+ > Write these first and confirm they fail before implementation.
149
+
150
+ - [X] T006 [P] [US1] Write `spec/ruby_reactor/dsl/step_input_contract_spec.rb` (red), covering declaration and introspection, with fixtures defined inline via `stub_const`: Forms 0/1/1b/1-opt/2/3 from `contracts/dsl-surface.md` §1 each appear in `declared_inputs` with the right `type`/`optional`/`predicates`/`macro_block`/`schema`; `required_input_names` returns the non-optional names in declaration order; `declares_inputs?` is false for a plain step and for `RubyReactor::Step::MapStep`, `ComposeStep`, `AsyncReactorStep` (research Finding 6); redeclaring a name replaces the earlier one; inheritance: the child's contract is the parent's plus its own, a same-named child input replaces the parent's, and the parent's contract is unchanged; `validate_inputs` blocks from parent and child both apply; `input :x, default: 1` without `optional: true` raises `RubyReactor::Error::ValidationError` at the `input` call; `input` raises `LoadError` with the existing install message when `Dry::Schema` is hidden (`hide_const("Dry::Schema")`); enforcement survives (a) `def self.run` written after `include` and (b) a subclass defining its own `def self.run` (Finding 2). For (b), call `Child.run({}, ctx)` with an invalid value and expect `InputValidationError`
151
+ - [X] T007 [P] [US1] Write `spec/ruby_reactor/step_contract_enforcement_spec.rb` (red), covering sync execution paths with explicit `argument` wiring (Finding 10). Record body calls in a local array. Cases: US1 AS1–AS6, where AS6 is `input :notify, :bool` receiving `false`; a failure's `validation_errors` has each offending field, `step_name == :charge` (the reactor step name, Finding 6), `reactor_name` is set, and the body array is empty; `test_reactor(...)` + `have_validation_error(:amount)` matches; a completed prior step with `compensate` is rolled back (saga); a step with `retries max_attempts: 3` validates once and its body is never attempted; direct call `Step.run({ amount: 0 }, ctx)` raises `InputValidationError` with `step_name == "<StepClass>"` and the same `field_errors` as the reactor run (SC-010); `optional: true, default: "x"` applies for an absent key and for `nil`, not for `false`; `redact: true` shows `"[REDACTED]"` in `failure.step_arguments`, in `failure.message`, and in the `:run` execution-trace entry's `arguments`; a `map` over the class step fails when one element violates the contract; a step whose `where` is false produces no validation error; the same class used under two step names is validated independently for each
152
+
153
+ ### Implementation for User Story 1
154
+
155
+ - [X] T008 [US1] Create `RubyReactor::Step::InputContract` in `lib/ruby_reactor/step/input_contract.rb` per Shared names. `input` calls `check_dry_validation_available!` eagerly, raises `Error::ValidationError` for `default:` without `optional: true`, stores a `Declaration`, and resets the memoized validators. The per-declaration validator comes from `build_declaration_validator` (T005). `enforce!(args)`: (1) apply defaults where the key is absent or the value is `nil` (never for `false`); (2) for each declaration with a validator, skip it when optional and the key is absent, otherwise call `validator.call({ name => value })` and merge `field_errors`, the same shape as `Dsl::Reactor::ClassMethods#validate_inputs` (`dsl/reactor.rb:182`); (3) run each `validate_inputs` block/schema (`create_input_validator`) over the whole hash, last, merging its errors over earlier ones; (4) on errors raise `Error::InputValidationError.new(errors)` with `step_arguments = redact(args)`; (5) otherwise return the args-with-defaults hash itself, not the schema output (Finding 4). `merge(child)` returns a new contract with `declarations.merge(child.declarations)` and both block lists concatenated
156
+ - [X] T009 [US1] In `lib/ruby_reactor/step.rb`, add to `Step::ClassMethods`: `input(...)` and `validate_inputs(...)` delegating to an own-class contract (`@own_input_contract ||= InputContract.new(owner: self)`); `input_contract` (superclass's `input_contract.merge(own)` when the superclass declares inputs, else own; memoized, and the memo is cleared by `input`/`validate_inputs`); `declared_inputs` (`input_contract.declarations`); `required_input_names`; `declares_inputs?` (`!input_contract.empty?`)
157
+ - [X] T010 [US1] In `lib/ruby_reactor/step.rb`, add `Step::InputEnforcement` with `def run(arguments, context)`: return `super` when `!declares_inputs?`; otherwise call `super(input_contract.enforce!(arguments), context)`, rescuing `Error::InputValidationError` to set `e.step_name = name` and re-raise. Prepend it in `Step.included(base)` (`base.singleton_class.prepend(InputEnforcement)`) and in a new `ClassMethods#inherited(subclass)` (`super` then prepend onto `subclass.singleton_class`) (Finding 2). `compensate`/`undo` are not wrapped (contract §7) (depends on T008, T009)
158
+ - [X] T011 [US1] In `lib/ruby_reactor/dsl/step_builder.rb`, add `StepConfig#input_contract` returning `impl.input_contract` when `impl.respond_to?(:declares_inputs?) && impl.declares_inputs?`, else nil. US3 extends it to inline contracts
159
+ - [X] T012 [US1] In `lib/ruby_reactor/executor/step_executor.rb`: (a) in `safe_execute_step_sync`'s `rescue Error::InputValidationError` (line 186), capture `=> e`, set `e.step_name = step_config.name` (overwrite, Finding 6) and `e.step_arguments ||= resolved_arguments`, then re-raise; (b) in `run_step_implementation`, write `arguments: step_config.input_contract ? step_config.input_contract.redact(arguments) : arguments` into the `:run` trace entry (Finding 7). T006 and T007 go green (depends on T010, T011)
160
+ - [X] T013 [US1] Create `spec/support/reactors/step_contract_reactors.rb` for the live worker (requires T009, Finding 9). Define `ContractChargeStep` (`input :amount, :integer, gteq?: 1`; `input :currency, :string, included_in?: %w[USD EUR]`; body returns `Success(args)`), `ContractAsyncStepReactor` (inputs `amount`, `currency`; `async_step :charge, ContractChargeStep` with explicit `argument` lines and `retries max_attempts: 3`; `step :confirm` with `argument :charge, result(:charge)` whose body returns `args[:charge]`, so a Failure from the worker becomes this reactor's failure, following `AsyncStepDemoReactor`'s reader pattern), and `ContractBackgroundReactor` (`background all: true`, one step using `ContractChargeStep`)
161
+ - [X] T014 [US1] Write `spec/ruby_reactor/step_contract_async_spec.rb` (red) using `for_each_real_async_backend`: `ContractAsyncStepReactor.run(amount: 0, currency: "USD")`. After the worker finishes (follow the waiting pattern in existing `for_each_real_async_backend` specs), the reactor's failure carries `validation_errors` with `:amount`, `step_name == :charge`, and `retryable == false`, and the worker attempted validation once (no retry). `amount: 5` succeeds. `ContractBackgroundReactor` with `amount: 0` fails the same way inside the hand-off worker (FR-003) (depends on T013)
162
+ - [X] T015 [US1] In `lib/ruby_reactor/step_worker.rb#execute_step_body`, add `rescue Error::InputValidationError => e` before the existing `rescue StandardError`, returning `RubyReactor.Failure(e, validation_errors: e.field_errors, step_name: @step_name, step_arguments: e.step_arguments || {}, reactor_name: @reactor_class_name, retryable: false)` (Finding 8). Class steps are already validated by the prepended `run`. T014 goes green (depends on T014)
163
+
164
+ **Checkpoint**: A step class owns its rules. Every execution path enforces them, and failures are attributed and redacted. MVP is shippable here.
165
+
166
+ ---
167
+
168
+ ## Phase 4: User Story 2 - The reactor wires values without redeclaring rules (Priority: P1)
169
+
170
+ **Goal**: For a contract-owning step, `argument` is wiring only. Rules in the reactor, or wiring for
171
+ an undeclared input, fail at the `step` macro (FR-006, FR-018).
172
+
173
+ **Independent Test**: A mapping-only reactor over a contract-owning step loads and runs. Adding a
174
+ typed `argument` or `validate_args` makes the class body raise, and the message names the reactor,
175
+ step, argument, and owning class.
176
+
177
+ ### Tests for User Story 2 ⚠️
178
+
179
+ - [X] T016 [P] [US2] Write `spec/ruby_reactor/dsl/step_contract_conflict_spec.rb` (red). Each case builds a reactor with `Class.new(RubyReactor::Reactor) { ... }` and `stub_const` so the name is stable. Cases: a mapping-only reactor (`argument :amount, input(:amount)`, with and without `transform:`) loads and the step's contract governs at run time (AS1); `argument :amount, input(:amount), :integer` raises `RubyReactor::Error::ValidationError` whose message includes the reactor name, `:charge`, `:amount`, and the step class name (AS2); a predicates-only `argument :amount, input(:amount), gt?: 0` raises the same; `validate_args do ... end` raises the same class of error (AS3); `argument :bogus, value(1)` raises naming `:bogus` (FR-018); `async_step :charge, ChargeStep` with a typed argument raises identically; a step class with **no** contract plus a typed `argument` loads and enforces the reactor's rule (AS4, unchanged behavior)
180
+
181
+ ### Implementation for User Story 2
182
+
183
+ - [X] T017 [US2] In `lib/ruby_reactor/dsl/step_builder.rb#build`, before creating the `StepConfig`, when `@impl.respond_to?(:declares_inputs?) && @impl.declares_inputs?`: raise `Error::ValidationError` if `@arg_validations` is non-empty (name the first offending argument) or `@validate_args_input` is set, and if any `@arguments` key is not in `@impl.input_contract.declarations`. Message (FR-006): `"#{reactor_label} step :#{@name} declares rules on argument :#{arg}, but #{@impl} owns its input contract. Move the rule into #{@impl} (`input :#{arg}, ...`) and keep only the wiring here: `argument :#{arg}, <source>`."`. Unknown argument (FR-018): `"#{reactor_label} step :#{@name} wires argument :#{arg}, which #{@impl} does not declare. Declared inputs: #{names}."`. Add a private `reactor_label` (`@reactor&.name || @reactor.inspect`). T016 goes green
184
+
185
+ **Checkpoint**: Wiring and rules are split for class steps, and conflicts are caught when the reactor class body runs.
186
+
187
+ ---
188
+
189
+ ## Phase 5: User Story 3 - Inline steps keep a single, coherent place for rules (Priority: P1)
190
+
191
+ **Goal**: `inputs do input ...; validate_inputs ... end` inside a `step` block. The lines are
192
+ identical to a step class's, and enforcement goes through the same `InputContract#enforce!`
193
+ (Finding 3).
194
+
195
+ **Independent Test**: The same declarations as an inline `inputs` block and as a step class, run
196
+ over the same conforming and violating inputs, give identical `success?`, value, and
197
+ `validation_errors`.
198
+
199
+ ### Tests for User Story 3 ⚠️
200
+
201
+ - [X] T018 [P] [US3] Write `spec/ruby_reactor/dsl/inline_step_contract_spec.rb` (red). Cases: an inline `inputs` block with `input :amount, :integer, gteq?: 1`, `input :currency, :string, included_in?: %w[USD EUR]`, and a `validate_inputs` cross-field block fails with the same failure shape as the class form (AS1); the equivalence table (SC-005): for each input set in `[{amount: 5, currency: "USD"}, {amount: 0, currency: "USD"}, {amount: 5, currency: "JPY"}, {amount: 20_000, currency: "EUR"}]`, the inline reactor and a reactor using the same lines pasted into a step class give equal `success?`, value, and `validation_errors` (AS2); inside the `step` block but outside `inputs`, `input(:amount)` still returns a `Template::Input` (research Finding 4); defaults and `redact:` behave as in T007; conflicts raise `Error::ValidationError`: `inputs` inside `step :x, SomeStepClass`, `inputs` plus a typed `argument`, `inputs` plus `validate_args`, and an `argument` naming an undeclared input; `inputs do ... end` inside an `interrupt` block raises and points at `validate_payload`
202
+
203
+ ### Implementation for User Story 3
204
+
205
+ - [X] T019 [US3] In `lib/ruby_reactor/dsl/step_builder.rb`: add `StepBuilder#inputs(&block)` that raises `Error::ValidationError` when `@impl` is set (`"`inputs` is for inline steps; declare `input` inside #{@impl}"`), otherwise builds `@inline_contract = Step::InputContract.new(owner: @name)` and `instance_eval`s the block on it. Pass `inline_contract:` into `StepConfig`, add the `StepConfig#inline_contract` reader, and change `StepConfig#input_contract` (T011) to return `inline_contract` when present. Extend T017's checks to inline contracts: typed `argument`/`validate_args` plus `inputs` raises, and an `argument` not declared in `inputs` raises (depends on T017)
206
+ - [X] T020 [US3] In `lib/ruby_reactor/executor/step_executor.rb#run_step_implementation`, in the `has_run_block?` branch, set `args_to_pass = step_config.inline_contract.enforce!(args_to_pass) if step_config.inline_contract` before `run_block.call`. T012(a) stamps the step name. T018 goes green (depends on T019)
207
+ - [X] T021 [US3] In `lib/ruby_reactor/dsl/interrupt_builder.rb`, override `inputs` to raise `Error::ValidationError`: `"interrupt :#{@name} does not take an `inputs` contract; validate the resume payload with `validate_payload`."` (Finding 11)
208
+ - [X] T022 [US3] Add `ContractInlineAsyncReactor` to `spec/support/reactors/step_contract_reactors.rb` (an `async_step :charge` with an `inputs` block matching `ContractChargeStep`, explicit `argument` lines, and the same `:confirm` reader). Add an example to `spec/ruby_reactor/step_contract_async_spec.rb` asserting the worker-side failure matches T014's, then confirm it fails (depends on T019)
209
+ - [X] T023 [US3] In `lib/ruby_reactor/step_worker.rb#execute_step_body`, in the `has_run_block?` branch, set `args = step_config.inline_contract.enforce!(args) if step_config.inline_contract`. T015's rescue produces the failure. T022 goes green (depends on T022)
210
+
211
+ **Checkpoint**: Inline and class steps share one vocabulary and one enforcement method, on every path.
212
+
213
+ ---
214
+
215
+ ## Phase 6: User Story 4 - Missing wiring is caught when the reactor is defined (Priority: P2)
216
+
217
+ **Goal**: A declared input with no `argument` resolves from the same-named reactor input. A
218
+ required input satisfied by neither raises before any step runs (FR-008, FR-020, FR-021).
219
+
220
+ **Independent Test**: `SignupReactor` (inputs matching the step's names, no `argument` lines) runs.
221
+ `IncompleteReactor` raises `Error::ValidationError` naming `:profile`, `:email`, and both remedies,
222
+ and no step has run.
223
+
224
+ ### Tests for User Story 4 ⚠️
225
+
226
+ - [X] T024 [P] [US4] Write `spec/ruby_reactor/dsl/step_contract_wiring_spec.rb` (red). Cases: US4 AS1–AS6 for a class step; the same inference for an inline step with an `inputs` block; `Reactor.validate_definition!` is public and callable without running; the error message contains the reactor name, step name, input name, `argument :<input>, ...`, and `input :<input>`; the error is raised from `.run` before any step runs (a preceding step pushes to a local array, which stays empty) and from `test_reactor(...)`; inferred entries in `steps[:x].arguments` have `origin: :inferred` and `source` a `Template::Input` for the same name; explicit entries have `origin: :explicit` and are never replaced (AS6); step results are never consulted: a prior step named `:email` does not satisfy a missing `:email` input, which still raises; calling `validate_definition!` twice does not change `arguments.size`; a `test_reactor(...).mock_step(...)` run still resolves by name (TestSubject subclasses the reactor, `test_subject.rb:501`); FR-019: an inline step without a contract or arguments still receives all reactor inputs
227
+ - [X] T025 [P] [US4] Add `ContractNameResolvedAsyncReactor` to `spec/support/reactors/step_contract_reactors.rb` (inputs `amount`, `currency`; `async_step :charge, ContractChargeStep` with **no** `argument` lines; the `:confirm` reader). Add an example to `spec/ruby_reactor/step_contract_async_spec.rb` asserting `amount: 5` succeeds in the live worker and `amount: 0` fails with `validation_errors[:amount]`. This fails until T028 (Finding 5)
228
+
229
+ ### Implementation for User Story 4
230
+
231
+ - [X] T026 [US4] In `lib/ruby_reactor/dsl/step_builder.rb#argument`, store `origin: :explicit` in the `@arguments[name]` hash alongside `source:` and `transform:`
232
+ - [X] T027 [US4] Add a public `validate_definition!` to `Dsl::Reactor::ClassMethods` in `lib/ruby_reactor/dsl/reactor.rb`. It returns immediately when `@definition_validated`. Otherwise, for each `steps` value that responds to `input_contract` and has a non-nil one, for each declared name: skip if `arguments.key?(name)`; else if `inputs.key?(name)`, add `arguments[name] = { source: Template::Input.new(name), transform: nil, origin: :inferred }`; else, if the input is required, raise `Error::ValidationError` with `"#{name || inspect} step :#{step} requires input :#{input}, which is neither wired nor a reactor input. Wire it (`argument :#{input}, input(:x)` / `result(:step)`) or declare `input :#{input}` on the reactor."`. Then set `@definition_validated = true`. Add `# ponytail: a reactor reopened after its first run is not re-checked`
233
+ - [X] T028 [US4] Call `validate_definition!` from three places (Finding 5): the first line of `RubyReactor::Reactor#run` (`lib/ruby_reactor/reactor.rb:88`), as `self.class.validate_definition!`, so it raises before the context is saved; `Executor#initialize` (`lib/ruby_reactor/executor.rb:21`), as `reactor_class.validate_definition! if reactor_class.respond_to?(:validate_definition!)`, before the dependency graph is built, which covers resume, background, map, and compose workers; and `StepWorker#perform_unit` (`lib/ruby_reactor/step_worker.rb:57`), as `context.reactor_class.validate_definition!` before the step lookup. T024 and T025 go green (depends on T026, T027)
234
+
235
+ **Checkpoint**: Reactors whose input names match their steps need no `argument` lines, and incomplete wiring fails before step one.
236
+
237
+ ---
238
+
239
+ ## Phase 7: User Story 5 - Existing reactors keep working through the transition (Priority: P2)
240
+
241
+ **Goal**: Rules on `argument`/`validate_args` for steps without a contract behave exactly as
242
+ before and print one deprecation notice per declaration site (FR-010, FR-011).
243
+
244
+ **Independent Test**: An unchanged reactor with typed arguments on an inline step returns the same
245
+ results and errors, and prints one `DEPRECATION` line per site.
246
+
247
+ ### Tests for User Story 5 ⚠️
248
+
249
+ - [X] T029 [P] [US5] Write `spec/ruby_reactor/step_contract_deprecation_spec.rb` (red). Cases: defining a reactor with `argument :amount, input(:amount), :integer, gteq?: 1` on an inline step prints to stderr (`output(/\[RubyReactor\] DEPRECATION:.*step :charge.*argument :amount.*input :amount/).to_stderr`) and names the defining file and line; `validate_args` prints a notice naming `inputs do ... validate_inputs`; defining the same reactor twice from one call site (two `Class.new` calls in a loop) prints once; a class step with no contract plus a typed argument also prints, and its message names the class; conforming and violating runs of each reactor give the same `success?`, value, `validation_errors`, and `step_name` as before (AS1, AS2); a mapping-only `argument` prints nothing
250
+
251
+ ### Implementation for User Story 5
252
+
253
+ - [X] T030 [US5] In `lib/ruby_reactor/dsl/step_builder.rb`, record `caller_locations(1, 1).first` in `argument` (when a type or predicates are given) and in `validate_args`. In `build`, after T017/T019's conflict checks pass, `warn` once per `"#{path}:#{lineno}"`, tracked in a `Set` held in a `StepBuilder` class-level ivar: `"[RubyReactor] DEPRECATION: #{path}:#{lineno} #{reactor_label} step :#{@name} declares rules on `argument :#{arg}`. Declare them on the step instead (`input :#{arg}, ...` in #{@impl || 'an `inputs do ... end` block'}) and keep `argument :#{arg}, <source>` for wiring. Removal no earlier than the next MAJOR."`. The `validate_args` variant points at `validate_inputs` (Finding 12). T029 goes green
254
+ - [X] T031 [US5] Run `bundle exec rspec` and compare with `specs/002-step-input-contracts/baseline.txt`. Every example that passed at baseline must still pass without edits (SC-006). Deprecation output is expected. The only allowed behavior change is FR-023: an example that asserted `false → nil` gets fixed and listed in the CHANGELOG Bug Fixes entry (T038)
255
+
256
+ **Checkpoint**: Upgrading changes nothing but stderr notices and the falsey fix.
257
+
258
+ ---
259
+
260
+ ## Phase 8: Polish & Cross-Cutting Concerns
261
+
262
+ **Purpose**: Docs, changelog, and the constitution-required demo and acceptance run
263
+
264
+ **Constitution Principle VI — Demo-App Proof of Feature (required for any public API change):**
265
+
266
+ - [X] T032 [P] Create `demo_app/app/reactors/validated_user_step.rb` (`ValidatedUserStep`, following the `ReserveInventory` file precedent) with the exact contract from quickstart Scenario 1 (`name` min_size 2, `email`, `age` gteq 18, optional `bio` with default and max_size 100, `marketing_opt_in, :bool`). `run` logs via `Rails.logger` and returns `Success(args.merge(created_at: Time.current.iso8601))`
267
+ - [X] T033 [P] Create `demo_app/app/reactors/validated_signup_reactor.rb` (`ValidatedSignupReactor`: inputs `name`, `email`, `age`, `marketing_opt_in`; `step :profile, ValidatedUserStep` with no `argument` lines; `returns :profile`) and `demo_app/app/reactors/validated_signup_async_reactor.rb` (`ValidatedSignupAsyncReactor`: same inputs; `async_step :profile, ValidatedUserStep`; `step :welcome` reading `result(:profile)` and returning it). Neither reactor declares a rule (SC-001), and both reuse one step class (SC-002) (depends on T032)
268
+ - [X] T034 Register `demo:validated_signup` in `demo_app/lib/tasks/demo_reactors.rake` with a `desc` and `[:environment, :flush_redis]`, following `demo:signal_demo`'s `run_*` helper style. Print ✅/❌ lines for: a passing run (bio defaulted); `name: "A", age: 17` (print `validation_errors` and `step_name`); `marketing_opt_in: false` passing with `false` shown; the async variant failing inside the worker with the same `validation_errors`; and `ValidatedUserStep.run({ age: 17 }, nil)` rescued and printed (depends on T033)
269
+ - [X] T035 Write `demo_app/spec/reactors/validated_signup_reactor_spec.rb` (`type: :reactor`) using only `test_reactor`, `be_success`, `be_failure`, `have_validation_error`, `step_result`: the passing run and bio default; `have_validation_error(:name)` and `have_validation_error(:age)`; `marketing_opt_in: false` succeeds and `step_result(:profile)[:marketing_opt_in] == false`; the async variant (follow `demo_app/spec/reactors/async_step_demo_reactor_spec.rb`) fails with `have_validation_error(:age)`. If any assertion can't be expressed with the shipped surface, add the matcher to `lib/ruby_reactor/rspec/matchers.rb` in this change, never hand-rolled (depends on T033)
270
+ - [X] T036 Check `docker-compose.yml` (`demo-app`, `demo-sidekiq`, demo Redis). No new service or env var should be needed. If the async variant needs one, add it here (Constitution VI.4)
271
+
272
+ **Docs and release notes:**
273
+
274
+ - [X] T037 [P] Update `README.md`: in "Defining Steps" (line ~224), make the class-step example declare `input` lines. Rewrite "Step Argument & Output Validation" (line ~1021) to show the step-class contract first, the inline `inputs do` equivalent, the name-based resolution rule, the conflict and unknown-argument errors, the explicit "no per-reactor overrides: write two steps or relax the contract" note (spec Edge Cases), presence semantics (contract §9), and a migration block from `argument :x, src, :type, **rules` / `validate_args` to `input`/`validate_inputs`. Note that `validate_definition!` can run in an initializer or CI (FR-016)
275
+ - [X] T038 [P] Add to `CHANGELOG.md` under `## Unreleased`: **Features**, covering step input contracts (`input`/`validate_inputs` on step classes, `inputs do` for inline steps, name-based resolution, `validate_definition!`) plus the migration note and the deprecation of rules on `argument`/`validate_args` (removal no earlier than the next MAJOR); **Bug Fixes**, noting that a supplied `false` reactor input or step result no longer resolves to `nil` (`Context#get_input`, `#get_result`, `Template::Result#fetch`)
276
+
277
+ **Verification:**
278
+
279
+ - [X] T039 Run `bundle exec rubocop` with no `--disable-pending-cops`. The offense count must not exceed the baseline in `specs/002-step-input-contracts/baseline.txt`, and there are no new offenses in touched files
280
+ - [X] T040 Run the docker acceptance: `docker compose up -d demo-redis demo-sidekiq`, `docker compose run --rm demo-app bin/rails demo:validated_signup` (every printed line matches T034's expectations), and `docker compose run --rm demo-app bundle exec rspec spec/reactors/validated_signup_reactor_spec.rb` (SC-008)
281
+ - [X] T041 Walk quickstart.md Scenarios 1–6 and its acceptance checklist SC-001…SC-011, ticking each against the spec or demo output that proves it. Run the full `bundle exec rspec` one final time and compare it with the baseline
282
+
283
+ ---
284
+
285
+ ## Dependencies & Execution Order
286
+
287
+ ### Phase Dependencies
288
+
289
+ - **Setup (Phase 1)**: No dependencies
290
+ - **Foundational (Phase 2)**: Depends on T001. Blocks all stories (US1 AS6 and defaults need T004; `InputContract` needs T005)
291
+ - **US1 (Phase 3)**: Depends on Phase 2
292
+ - **US2 (Phase 4)**: Depends on US1 (`declares_inputs?`, `input_contract` on step classes)
293
+ - **US3 (Phase 5)**: Depends on US1 (`InputContract`, T012 stamping, T015 worker rescue) and US2 (T019 extends T017's checks)
294
+ - **US4 (Phase 6)**: Depends on US1. Its inline-step case also needs US3's T019
295
+ - **US5 (Phase 7)**: Depends on US2 and US3 (the notice is emitted only after their conflict checks pass)
296
+ - **Polish (Phase 8)**: T032–T036 need US1 + US4 (the demo reactor has no `argument` lines). T037–T041 need all stories
297
+
298
+ ### User Story Dependencies
299
+
300
+ ```text
301
+ Phase 2 ──► US1 ──► US2 ──► US3 ──► US5
302
+ │ ▲
303
+ └──────► US4 ─────┘ (inline case only)
304
+ ```
305
+
306
+ ### Within Each User Story
307
+
308
+ - Spec task first, confirmed red, then implementation, then green
309
+ - `step.rb` tasks (T009 → T010) are sequential: same file
310
+ - `step_builder.rb` tasks (T011, T017, T019, T026, T030) are sequential across phases: same file
311
+ - `step_contract_reactors.rb` / `step_contract_async_spec.rb` (T013, T014, T022, T025) are sequential: same files
312
+
313
+ ### Parallel Opportunities
314
+
315
+ - Phase 2: T002, T003, T005 together (then T004 after T003)
316
+ - US1: T006 ∥ T007 (both red before any lib change)
317
+ - US2 ∥ US4 test writing: T016 ∥ T024 ∥ T025 once US1 is green
318
+ - US4 T024 ∥ US3 T018 (different spec files)
319
+ - US5: T029 can be written any time after US1
320
+ - Polish: T032 ∥ T037 ∥ T038, then T033 → T034/T035
321
+
322
+ ---
323
+
324
+ ## Parallel Example: User Story 1
325
+
326
+ ```bash
327
+ # Both red specs at once:
328
+ Task: "T006 Write spec/ruby_reactor/dsl/step_input_contract_spec.rb (declaration, introspection, inheritance, subclass wrapping)"
329
+ Task: "T007 Write spec/ruby_reactor/step_contract_enforcement_spec.rb (sync paths, attribution, redaction, rollback, direct call)"
330
+
331
+ # After US1 is green, the next stories' specs at once:
332
+ Task: "T016 Write spec/ruby_reactor/dsl/step_contract_conflict_spec.rb"
333
+ Task: "T018 Write spec/ruby_reactor/dsl/inline_step_contract_spec.rb"
334
+ Task: "T024 Write spec/ruby_reactor/dsl/step_contract_wiring_spec.rb"
335
+ Task: "T029 Write spec/ruby_reactor/step_contract_deprecation_spec.rb"
336
+ ```
337
+
338
+ ---
339
+
340
+ ## Implementation Strategy
341
+
342
+ ### MVP First (User Story 1 Only)
343
+
344
+ 1. T001 baseline → Phase 2 (falsey fix + shared dispatch)
345
+ 2. Phase 3: step-class contracts enforced on every path, including the live worker
346
+ 3. **STOP and VALIDATE**: T006, T007, T014 green. Full suite at baseline
347
+ 4. Shippable on its own: authors can move rules into step classes, and reactors using them wire
348
+ inputs with explicit `argument` lines
349
+
350
+ ### Incremental Delivery
351
+
352
+ 1. + US2 → reactor-side conflicts rejected (the ambiguity the feature exists to remove)
353
+ 2. + US3 → inline steps get the same vocabulary. The equivalence spec pins both forms
354
+ 3. + US4 → name-based wiring and fail-before-step-one satisfiability
355
+ 4. + US5 → deprecation notices. Migration is documented
356
+ 5. Polish → demo app, README, CHANGELOG, docker acceptance
357
+
358
+ ---
359
+
360
+ ## Notes
361
+
362
+ - [P] = different files, no dependency on an incomplete task
363
+ - Run `docker compose up -d redis-test` before any spec. `spec_helper` aborts without Redis
364
+ - The deviations from research.md, D4 (no `args_validator` for inline contracts) and D3
365
+ (overwrite, not `||=`), are explained in Findings 3 and 6. Reflect them in plan.md's Complexity
366
+ Tracking row if a reviewer asks: there is now one enforcement method, not two mechanisms
367
+ - Commit after each green checkpoint
@@ -0,0 +1,40 @@
1
+ # Specification Quality Checklist: Inheritable Step Class
2
+
3
+ **Purpose**: Validate specification completeness and quality before proceeding to planning
4
+ **Created**: 2026-09-11
5
+ **Feature**: [spec.md](../spec.md)
6
+
7
+ ## Content Quality
8
+
9
+ - [x] No implementation details (languages, frameworks, APIs)
10
+ - [x] Focused on user value and business needs
11
+ - [x] Written for non-technical stakeholders
12
+ - [x] All mandatory sections completed
13
+
14
+ ## Requirement Completeness
15
+
16
+ - [x] No [NEEDS CLARIFICATION] markers remain
17
+ - [x] Requirements are testable and unambiguous
18
+ - [x] Success criteria are measurable
19
+ - [x] Success criteria are technology-agnostic (no implementation details)
20
+ - [x] All acceptance scenarios are defined
21
+ - [x] Edge cases are identified
22
+ - [x] Scope is clearly bounded
23
+ - [x] Dependencies and assumptions identified
24
+
25
+ ## Feature Readiness
26
+
27
+ - [x] All functional requirements have clear acceptance criteria
28
+ - [x] User scenarios cover primary flows
29
+ - [x] Feature meets measurable outcomes defined in Success Criteria
30
+ - [x] No implementation details leak into specification
31
+
32
+ ## Notes
33
+
34
+ - Feature is a public-API refactor of a Ruby library, so the spec necessarily names the
35
+ public authoring surface (`RubyReactor::Step`, `run`, `fail!`, `call`). These are the
36
+ product, not implementation details; internal mechanics (prepend, singleton wrapping)
37
+ appear only in the Input quote and in FR-011 as things that must NOT exist.
38
+ - Key scope decision (single authoring style, mixin removed) resolved via the decision rule
39
+ the description supplied; recorded under Assumptions rather than left as a clarification.
40
+ - Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan`
@@ -0,0 +1,85 @@
1
+ # Public API Contract: `RubyReactor::Step`
2
+
3
+ RubyReactor is a library (Constitution Principle I) — its "contract" is the public Ruby
4
+ API surface it exposes to gem consumers, not a network endpoint. This document is that
5
+ contract for the step-authoring surface this feature replaces.
6
+
7
+ ## Authoring contract
8
+
9
+ ```ruby
10
+ class MyStep < RubyReactor::Step
11
+ input :email, :string, format?: EMAIL_REGEX
12
+ input :user_id, :integer
13
+
14
+ def run
15
+ # `inputs` and `context` expose the validated arguments and the workflow context.
16
+ # Return a result wrapper, or end early with a signal.
17
+ fail!("nop") unless something
18
+ Success(value: inputs[:user_id])
19
+ end
20
+
21
+ def undo
22
+ # `result` holds this step's own stored result value.
23
+ Skipped() # default if omitted
24
+ end
25
+
26
+ def compensate
27
+ # `reason` holds the failure that triggered rollback.
28
+ Skipped() # default if omitted
29
+ end
30
+ end
31
+ ```
32
+
33
+ Instance readers: `inputs`, `context`, `result` (undo only), `reason` (compensate only).
34
+ All set once in the constructor; see data-model.md. `inputs` holds the same values in every
35
+ action: the arguments with the contract's defaults applied.
36
+
37
+ - **MUST** subclass `RubyReactor::Step`. `include RubyReactor::Step` on a plain class is
38
+ no longer supported (FR-012) — it is not a compatibility path, it is simply gone.
39
+ - **MUST** declare inputs, if any, with `input`/`validate_inputs` at the class level,
40
+ identically to the input-contracts feature (FR-002). Declaring none means no validation
41
+ runs, and `run` receives the raw resolved arguments (unchanged behavior).
42
+ - **MUST** implement `run` as an instance method with no required parameters. Omitting it
43
+ raises `NotImplementedError` naming the subclass when the step is invoked (FR-010).
44
+ - **MAY** implement `undo`/`compensate` as instance methods with no required parameters;
45
+ omitting either defaults to `Skipped()` (FR-009).
46
+ - Inside `run`/`undo`/`compensate`, `Success`, `Failure`, `Halt`, `Skipped`, and the
47
+ signal helpers `success!`/`skip!`/`fail!`/`halt!` are available as bare instance calls
48
+ (FR-008). `Failure` takes exactly the arguments `RubyReactor.Failure` takes.
49
+
50
+ ## Invocation contract (what every caller in the library gets)
51
+
52
+ ```ruby
53
+ RubyReactor::Step.run(arguments, context) # => Success/Failure/Halt/Skipped
54
+ RubyReactor::Step.call(arguments, context) # alias of .run
55
+ RubyReactor::Step.undo(result, arguments, context) # => Success/Failure/Halt/Skipped
56
+ RubyReactor::Step.compensate(reason, arguments, context) # => Success/Failure/Halt/Skipped
57
+ ```
58
+
59
+ - **MUST** be pure from the caller's perspective: given the same `arguments`/`context`
60
+ (and `result`/`reason` for undo/compensate), behavior does not depend on whether `.run`
61
+ was previously called on the same class in the same process (FR-009, D2).
62
+ - **MUST NOT** raise `StepSignals`' internal throw to the caller — every signal is
63
+ translated into the matching result wrapper before `.run`/`.undo`/`.compensate` return
64
+ (FR-007).
65
+ - **MUST** raise the existing `Error::InputValidationError` (with `step_name` and
66
+ `step_arguments` set) when the declared contract rejects `arguments`, before the
67
+ instance's `run` method executes (FR-006) — same error class and same attributes as
68
+ today, only relocated internally (D-series decisions in research.md).
69
+ - That error's `retryable?` **MUST** be `false` (FR-017, research.md D10), so any `Failure`
70
+ built from it — on the synchronous path, the async worker, or surfaced through a
71
+ composed reactor — is non-retryable without its caller having to say so.
72
+ - `.undo`/`.compensate` **MUST NOT** enforce the contract, so rollback cannot fail on the
73
+ inputs that caused the failure. They **MUST** still apply its defaults, so `inputs` matches
74
+ what `.run` saw.
75
+ - An ordinary (non-signal) exception raised inside `run`/`undo`/`compensate` **MUST**
76
+ propagate unchanged — this contract governs signals and validation only.
77
+
78
+ ## Compatibility note
79
+
80
+ This is a **breaking change** to the public API (Constitution Principle V — SemVer). No
81
+ prior form is preserved. Any code outside this repository using
82
+ `include RubyReactor::Step` with `def self.run` must convert to
83
+ `class Foo < RubyReactor::Step` with an instance `def run` to keep working. `CHANGELOG.md`
84
+ records this under a breaking-change heading with a before/after example (spec FR-015,
85
+ User Story 5); release-please's `bump-minor-pre-major` takes 0.7.0 to 0.8.0.
@@ -0,0 +1,116 @@
1
+ # Phase 1 Data Model: Inheritable Step Class
2
+
3
+ This feature has no persisted schema or database entities — it reshapes an in-memory
4
+ authoring/execution abstraction. "Entities" here are Ruby-level concepts, matching the
5
+ Key Entities section of spec.md, made concrete for implementation.
6
+
7
+ ## `RubyReactor::Step` (the base class)
8
+
9
+ The single inheritable parent every class-based step derives from. Replaces the current
10
+ `module RubyReactor::Step` (mixin) — same constant name, different kind of object (a
11
+ `Class`, not a `Module`), per spec FR-001 and FR-012.
12
+
13
+ **Class-level surface** (the lifecycle, callable by every execution path per D1):
14
+
15
+ | Method | Signature | Behavior |
16
+ |---|---|---|
17
+ | `self.run` | `(arguments, context)` | Enforce input contract if declared (skip if `declares_inputs?` is false; a violation *raises* `InputValidationError` with `step_name` set, outside any catch) → `new(validated, context)` (D2/D3) → `catch(StepSignals::TAG) { instance.run }` → return the result wrapper or the caught signal's wrapper |
18
+ | `self.call` | `(arguments, context)` | Alias of `self.run` (D1) |
19
+ | `self.undo` | `(result, arguments, context)` | `new(arguments, context, result: result)` — fresh instance (D2/D3) → `catch(StepSignals::TAG) { instance.undo }` |
20
+ | `self.compensate` | `(reason, arguments, context)` | `new(arguments, context, reason: reason)` — fresh instance (D2/D3) → `catch(StepSignals::TAG) { instance.compensate }` |
21
+
22
+ The class-level entry points own the `catch` — that is what makes `StepWorker` (which has
23
+ no catch of its own today, see research.md D4) behave identically to the executor for
24
+ class steps without touching the worker.
25
+
26
+ **Class-level DSL surface** (unchanged from today, ported verbatim from the current
27
+ `ClassMethods` module per D5/D6 — ***not*** part of this feature's design decisions, just
28
+ carried forward):
29
+
30
+ - `input(...)`, `validate_inputs(...)` — delegate to `own_input_contract` (unchanged
31
+ `InputContract`, `lib/ruby_reactor/step/input_contract.rb`, untouched by this feature).
32
+ - `input_contract`, `declared_inputs`, `required_input_names`, `declares_inputs?` —
33
+ unchanged, including the parent-first `merge` on `inherited` (spec FR-002, User Story 4).
34
+ - `inherited(subclass)` — **deleted.** Today it exists solely to re-prepend
35
+ `InputEnforcement` onto each subclass's singleton class; contract memoization
36
+ (`@input_contract`, `@own_input_contract`) is already per-class instance variables and
37
+ needs no reset. With the prepend gone, the hook has nothing left to do.
38
+
39
+ **Result-wrapper / signal helpers available inside instance methods** (spec FR-008):
40
+ `Success`, `Failure`, `Halt`, `Skipped` and the `StepSignals` throw helpers (`success!`,
41
+ `skip!`, `fail!`, `halt!`) must be callable as bare instance methods inside `run`/`undo`/
42
+ `compensate` bodies — the base class includes `RubyReactor::StepSignals` at the instance
43
+ level (not only, as today, at the class/singleton level) and defines the same four
44
+ `Success`/`Failure`/`Halt`/`Skipped` wrapper methods as instance methods, delegating to
45
+ the module-level `RubyReactor.Success` etc.
46
+
47
+ ## `Step` instance (short-lived, per lifecycle action)
48
+
49
+ Built fresh for every `run`/`undo`/`compensate` call (D2) — never reused, never crosses a
50
+ process boundary as an object (only its inputs do, via the normal `arguments`/`context`/
51
+ `result`/`reason` that already flow through Redis-backed context serialization
52
+ unaffected by this feature).
53
+
54
+ Constructor: `initialize(inputs, context, result: nil, reason: nil)` (research.md D3).
55
+
56
+ | Reader | Holds | Set by | Read by |
57
+ |---|---|---|---|
58
+ | `inputs` | the argument hash with the contract's defaults applied (raw if no contract), identical in every action | `self.run` after enforcement; `self.undo`/`self.compensate` apply defaults only, never enforce | `run`, `undo`, `compensate` |
59
+ | `context` | the workflow `RubyReactor::Context` | constructor | `run`, `undo`, `compensate` |
60
+ | `result` | the step's own stored result value | `self.undo`'s `result` parameter; nil otherwise | `undo` |
61
+ | `reason` | the failure that triggered rollback | `self.compensate`'s `reason` parameter; nil otherwise | `compensate` |
62
+
63
+ All four are plain `attr_reader`s set once in the constructor. No other mutable state. An
64
+ instance built for `run` is discarded after `self.run` returns; `undo`/`compensate` never
65
+ read anything a prior `run` instance touched (this is the D2 guarantee that closes the
66
+ Complexity Tracking risk in plan.md).
67
+
68
+ Namespace: `RubyReactor::Step` is now a class, so the four constants under it
69
+ (`Step::InputContract`, `Step::ComposeStep`, `Step::MapStep`, `Step::AsyncReactorStep`)
70
+ live inside a class rather than a module — every file that opens the namespace must say
71
+ `class Step` (research.md D9).
72
+
73
+ ## `InputContract` (existing, unchanged)
74
+
75
+ No structural change. Still owns `declarations`, `cross_field_validators`, `#enforce!`,
76
+ `#merge` (parent-first), `#empty?`/`declares_inputs?`. This feature relocates *who calls*
77
+ `#enforce!` (from a `singleton_class.prepend`-installed `InputEnforcement#run` to
78
+ `self.run`'s own body) without touching the contract's own behavior — every acceptance
79
+ scenario in the prior feature (002-step-input-contracts) that exercises `InputContract`
80
+ directly is unaffected.
81
+
82
+ ## Result wrapper / Signal (existing, mostly unchanged)
83
+
84
+ `Success`, `Failure`, `Halt`, `Skipped` (module-level constructors in `RubyReactor`) and
85
+ `StepSignals::TAG` throw/catch mechanism are unchanged in shape and semantics — this
86
+ feature only relocates *where* the `catch(StepSignals::TAG)` for a class-based step's own
87
+ body lives (D4), not what a signal means or how it is thrown.
88
+
89
+ One property is fixed, not just relocated: a `Failure` built from a validation error is
90
+ always non-retryable (`retryable?` returns `false`), regardless of which execution path
91
+ produced it. This is enforced once, on `Error::InputValidationError#retryable?` itself
92
+ (research.md D10) — `RubyReactor::Failure` already asks the wrapped error object, so no
93
+ call site (synchronous executor, async worker, compose) has to remember to say so.
94
+
95
+ ## Relationships
96
+
97
+ ```text
98
+ RubyReactor::Step (class)
99
+ │ subclassed by
100
+
101
+ Concrete step class (e.g. MyStep, ComposeStep, MapStep, AsyncReactorStep)
102
+ │ declares (class-level DSL) │ invoked by (class-level entry points)
103
+ ▼ ▼
104
+ InputContract (declarations, merge) Executor::StepExecutor / CompensationManager /
105
+ RSpec::TestSubject (unchanged call sites)
106
+ │ enforced by self.run, producing
107
+
108
+ validated argument hash ──────────────► Step instance (per-action, D2)
109
+ │ run/undo/compensate body executes,
110
+ │ may throw a Signal
111
+
112
+ StepSignals::TAG catch (D4, at self.run/undo/compensate)
113
+
114
+
115
+ Result wrapper (Success/Failure/Halt/Skipped)
116
+ ```