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
@@ -1,42 +1,137 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- module Step
5
- def self.included(base)
6
- base.extend(ClassMethods)
4
+ # The single inheritable base every class-based step derives from:
5
+ #
6
+ # class MyStep < RubyReactor::Step
7
+ # input :amount, :integer
8
+ # def run = Success(charged: inputs[:amount])
9
+ # end
10
+ #
11
+ # Lifecycle of every class-level call (`.run`/`.call`, `.undo`, `.compensate`):
12
+ #
13
+ # 1. Resolve `inputs`: the given arguments with the contract's defaults
14
+ # applied. `run`, `undo`, and `compensate` all see the same values.
15
+ # 2. `.run` ONLY: enforce the declared input contract, raising
16
+ # `Error::InputValidationError` before any instance exists. `.undo` and
17
+ # `.compensate` NEVER enforce it: rollback must not fail on the very
18
+ # inputs that may have caused the failure.
19
+ # 3. Build a FRESH instance, never reused across actions. An ivar set in
20
+ # `run` is gone by the time `undo` runs on its own instance, so async
21
+ # execution running `run` and `undo` in different processes behaves
22
+ # identically to running both in one.
23
+ # 4. Invoke the matching instance method, translating any `StepSignals`
24
+ # throw (`success!`/`skip!`/`fail!`/`halt!`) into its result wrapper.
25
+ #
26
+ # No `prepend`/`extend`/`define_method`/`method_missing` — every step in the
27
+ # class reads top to bottom as ordinary method calls.
28
+ class Step
29
+ include RubyReactor::StepSignals
30
+
31
+ attr_reader :inputs, :context, :result, :reason
32
+
33
+ def initialize(inputs, context, result: nil, reason: nil)
34
+ @inputs = inputs
35
+ @context = context
36
+ @result = result
37
+ @reason = reason
38
+ end
39
+
40
+ def run
41
+ raise NotImplementedError, "#{self.class} must implement #run"
42
+ end
43
+
44
+ def undo
45
+ RubyReactor.Skipped()
46
+ end
47
+
48
+ def compensate
49
+ RubyReactor.Skipped()
7
50
  end
8
51
 
9
- module ClassMethods
10
- include RubyReactor::StepSignals
52
+ # rubocop:disable Naming/MethodName
53
+ def Success(value = nil)
54
+ RubyReactor.Success(value)
55
+ end
56
+
57
+ def Failure(...)
58
+ RubyReactor.Failure(...)
59
+ end
60
+
61
+ def Halt(reason: nil, **kwargs)
62
+ RubyReactor.Halt(reason: reason, **kwargs)
63
+ end
64
+
65
+ def Skipped(...)
66
+ RubyReactor.Skipped(...)
67
+ end
68
+ # rubocop:enable Naming/MethodName
11
69
 
12
- # rubocop:disable Naming/MethodName
13
- def Success(value = nil)
14
- RubyReactor::Success(value)
70
+ class << self
71
+ def run(arguments, context)
72
+ validated = enforce_contract!(arguments)
73
+ catch(StepSignals::TAG) { new(validated, context).run }
15
74
  end
75
+ alias call run
16
76
 
17
- def Failure(error = nil)
18
- RubyReactor::Failure(error)
77
+ # Same `inputs` as `.run` (defaults applied), but NEVER enforces the contract.
78
+ def undo(result, arguments, context)
79
+ catch(StepSignals::TAG) { new(with_defaults(arguments), context, result: result).undo }
19
80
  end
20
81
 
21
- def Halt(reason: nil, **kwargs)
22
- RubyReactor.Halt(reason: reason, **kwargs)
82
+ # Same `inputs` as `.run` (defaults applied), but NEVER enforces the contract.
83
+ def compensate(reason, arguments, context)
84
+ catch(StepSignals::TAG) { new(with_defaults(arguments), context, reason: reason).compensate }
23
85
  end
24
86
 
25
- def Skipped(...)
26
- RubyReactor.Skipped(...)
87
+ def input(...)
88
+ own_input_contract.input(...)
89
+ @input_contract = nil
27
90
  end
28
- # rubocop:enable Naming/MethodName
29
91
 
30
- def run(arguments, context)
31
- raise NotImplementedError, "#{self} must implement .run method"
92
+ def validate_inputs(...)
93
+ own_input_contract.validate_inputs(...)
94
+ @input_contract = nil
95
+ end
96
+
97
+ def input_contract
98
+ @input_contract ||=
99
+ if superclass.respond_to?(:declares_inputs?) && superclass.declares_inputs?
100
+ superclass.input_contract.merge(own_input_contract)
101
+ else
102
+ own_input_contract
103
+ end
104
+ end
105
+
106
+ def declared_inputs
107
+ input_contract.declarations
108
+ end
109
+
110
+ def required_input_names
111
+ input_contract.required_names
112
+ end
113
+
114
+ def declares_inputs?
115
+ !input_contract.empty?
32
116
  end
33
117
 
34
- def compensate(_reason, _arguments, _context)
35
- RubyReactor.Skipped() # Default: nothing defined, rollback continues
118
+ private
119
+
120
+ def own_input_contract
121
+ @own_input_contract ||= Step::InputContract.new(owner: self)
36
122
  end
37
123
 
38
- def undo(_result, _arguments, _context)
39
- RubyReactor.Skipped() # Default: nothing defined, rollback continues
124
+ def with_defaults(arguments)
125
+ input_contract.apply_defaults(arguments)
126
+ end
127
+
128
+ def enforce_contract!(arguments)
129
+ return arguments unless declares_inputs?
130
+
131
+ input_contract.enforce!(arguments)
132
+ rescue Error::InputValidationError => e
133
+ e.step_name = name
134
+ raise
40
135
  end
41
136
  end
42
137
  end
@@ -9,8 +9,12 @@ module RubyReactor
9
9
  # Implemented as throw/catch rather than an exception: a `throw` passes
10
10
  # straight through `rescue Exception` while `ensure` blocks still run
11
11
  # (verified in research.md R2), so a step's own broad rescue cannot swallow
12
- # the author's intended outcome. The catching `catch(StepSignals::TAG)` lives
13
- # at each step-body invocation site (step_executor.rb, compensation_manager.rb).
12
+ # the author's intended outcome. For a class-based step, the catching
13
+ # `catch(StepSignals::TAG)` lives on RubyReactor::Step's own class-level
14
+ # `run`/`undo`/`compensate` (so every caller — the executor, the async
15
+ # worker, a direct call — gets identical translation for free); for an
16
+ # inline `run_block`/`compensate_block`/`undo_block` step, it lives at the
17
+ # invocation site in step_executor.rb / compensation_manager.rb.
14
18
  module StepSignals
15
19
  TAG = :ruby_reactor_step_signal
16
20
 
@@ -56,6 +56,9 @@ module RubyReactor
56
56
  context = load_step_context
57
57
  return record_missing_parent unless context
58
58
 
59
+ # A fresh worker process has never run the reactor, so the inferred
60
+ # wiring for name-resolved inputs does not exist here yet.
61
+ context.reactor_class&.validate_definition!
59
62
  step_config = context.reactor_class&.steps&.[](@step_name)
60
63
  return record_missing_step unless step_config
61
64
 
@@ -113,6 +116,7 @@ module RubyReactor
113
116
  result =
114
117
  if step_config.has_run_block?
115
118
  args = arguments.empty? ? context.inputs : arguments
119
+ args = step_config.inline_contract.enforce!(args) if step_config.inline_contract
116
120
  step_config.run_block.call(args, context)
117
121
  elsif step_config.has_impl?
118
122
  step_config.impl.run(arguments, context)
@@ -121,6 +125,12 @@ module RubyReactor
121
125
  end
122
126
 
123
127
  normalize(result)
128
+ rescue Error::InputValidationError => e
129
+ # Same shape the executor builds, and never retried: the same arguments
130
+ # fail the same contract on every attempt.
131
+ RubyReactor.Failure(e, validation_errors: e.field_errors, step_name: @step_name,
132
+ step_arguments: e.step_arguments || {}, reactor_name: @reactor_class_name,
133
+ retryable: false)
124
134
  rescue StandardError => e
125
135
  RubyReactor.Failure(e, step_name: @step_name, reactor_name: @reactor_class_name)
126
136
  end
@@ -158,16 +168,21 @@ module RubyReactor
158
168
  # Write first, publish second. The record is the answer; the signal only
159
169
  # saves the reader a fallback interval.
160
170
  def complete(result, context)
161
- storage.store_step_result(
162
- @step_context_id, @step_name,
163
- {
164
- "status" => "completed",
165
- "success" => result.success?,
166
- "result" => ContextSerializer.serialize_value(result.success? ? result.value : result.to_h),
167
- "completed_at" => Time.now.iso8601
168
- },
169
- @reactor_class_name
170
- )
171
+ record = {
172
+ "status" => "completed",
173
+ "success" => result.success?,
174
+ "result" => ContextSerializer.serialize_value(result.success? ? result.value : result.to_h),
175
+ "completed_at" => Time.now.iso8601
176
+ }
177
+ # A `halt!` reports success? == true but carries no value, so without
178
+ # this the reader cannot tell it from an ordinary success returning nil.
179
+ # `skip!` needs nothing extra: Skipped keeps its value, and the reader is
180
+ # meant to see that value exactly as a same-process step would.
181
+ if result.is_a?(RubyReactor::Halt)
182
+ record["signal"] = "halt"
183
+ record["reason"] = result.reason
184
+ end
185
+ storage.store_step_result(@step_context_id, @step_name, record, @reactor_class_name)
171
186
  log(result.success? ? :info : :warn, result.success? ? "completed" : "completed_with_failure")
172
187
  storage.publish(RubyReactor.async_step_channel(@step_context_id, @step_name), "done")
173
188
  result
@@ -76,7 +76,14 @@ module RubyReactor
76
76
  end
77
77
 
78
78
  value = ContextSerializer.deserialize_value(fetch(record, :result))
79
- return value if fetch(record, :success)
79
+ if fetch(record, :success)
80
+ # Same rule as Failure below: a halt has no same-process equivalent
81
+ # for a reader to mirror, so hand over the signal itself.
82
+ return RubyReactor.Halt(reason: fetch(record, :reason), step_name: @step_name) if
83
+ fetch(record, :signal).to_s == "halt"
84
+
85
+ return value
86
+ end
80
87
 
81
88
  RubyReactor::Failure.new(value)
82
89
  end
@@ -175,7 +182,7 @@ module RubyReactor
175
182
 
176
183
  # Records round-trip through JSON, so a key may come back as a string.
177
184
  def fetch(hash, key)
178
- hash[key] || hash[key.to_s]
185
+ Utils::FetchIndifferent.call(hash, key)
179
186
  end
180
187
 
181
188
  def extract_path(value, path)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Utils
5
+ # Presence-aware symbol/string lookup: a supplied `false` is returned as
6
+ # `false`, never swallowed by an `a || b` fallback into `nil`.
7
+ class FetchIndifferent
8
+ def self.call(hash, key)
9
+ hash.key?(key.to_sym) ? hash[key.to_sym] : hash[key.to_s]
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.7.1"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/ruby_reactor.rb CHANGED
@@ -225,6 +225,7 @@ module RubyReactor
225
225
  {
226
226
  success: false,
227
227
  error: error_message,
228
+ retryable: @retryable,
228
229
  step_name: @step_name,
229
230
  inputs: @inputs,
230
231
  redact_inputs: @redact_inputs,
@@ -316,8 +317,10 @@ module RubyReactor
316
317
  end
317
318
 
318
319
  def extract_attributes_from_hash(error_hash)
319
- # Ensure indifferent access
320
- err = ->(k) { error_hash[k.to_s] || error_hash[k.to_sym] }
320
+ # Presence-aware indifferent access: a serialized `false` (notably
321
+ # `retryable: false` on a validation failure) must survive the round trip
322
+ # rather than be swallowed by an `||` fallback into nil.
323
+ err = ->(k) { Utils::FetchIndifferent.call(error_hash, k) }
321
324
 
322
325
  {
323
326
  error: err[:message] || err[:error] || error_hash,
@@ -0,0 +1,49 @@
1
+ # Specification Quality Checklist: Step Input Contracts
2
+
3
+ **Purpose**: Validate specification completeness and quality before proceeding to planning
4
+ **Created**: 2026-09-10
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
+ - All items pass. 0 [NEEDS CLARIFICATION] markers remain.
35
+ - Resolved with the user on 2026-09-10:
36
+ - **Naming**: a unit of work declares `input`; the reactor keeps `argument` for wiring only
37
+ (FR-012).
38
+ - **Undeclared arguments**: reactor-load error for contract-owning steps (FR-018); steps
39
+ with no contract and no wiring keep today's pass-all-inputs behavior (FR-019).
40
+ - **Implicit wiring**: an unwired step input resolves from a same-named reactor input only,
41
+ checked at reactor-load time; never from another step's result (FR-020, FR-021).
42
+ - **Direct invocation**: the contract is enforced on every entry point, not only via a
43
+ reactor (FR-022).
44
+ - **Falsey values**: presence means "a value was supplied", never "the value is truthy";
45
+ the pre-existing loss of `false` during argument resolution is corrected as part of this
46
+ feature (FR-023, SC-011).
47
+ - "Non-technical stakeholders" is read as *developers who are not this library's
48
+ maintainers*: the spec names no Ruby constructs, gems, or file paths.
49
+ - Ready for `/speckit-plan`.
@@ -0,0 +1,193 @@
1
+ # Public DSL Contract: Step Input Contracts
2
+
3
+ **Feature**: `specs/002-step-input-contracts/` | **Date**: 2026-09-10
4
+
5
+ The gem's external interface is its DSL. This document is the contract that
6
+ `spec/ruby_reactor/dsl/` specs assert against and that README must match.
7
+
8
+ ## 1. `input` — declare a contract (step class)
9
+
10
+ ```ruby
11
+ input(name, type = nil, optional: false, default: nil, redact: false,
12
+ validate: nil, **predicates, &block)
13
+ ```
14
+
15
+ Available on any class that `include RubyReactor::Step`. Signature is intentionally identical
16
+ to the reactor's `input`, minus `transform:` (a step does not transform its own inputs — the
17
+ reactor's `argument` does that).
18
+
19
+ ```ruby
20
+ class ValidatedUserStep
21
+ include RubyReactor::Step
22
+
23
+ input :name, :string, min_size?: 2
24
+ input :email, :string
25
+ input :age, :integer, gteq?: 18
26
+ input :bio, :string, optional: true, default: "No bio provided", max_size?: 100
27
+ input :token, :string, redact: true
28
+
29
+ input :window do |i| # Form 2 — macro block
30
+ i.filled(:integer, gteq?: 1, lteq?: 24)
31
+ end
32
+
33
+ input :payload, validate: PayloadSchema # Form 3 — pre-built schema
34
+
35
+ def self.run(args, context)
36
+ Success(profile_from(args))
37
+ end
38
+ end
39
+ ```
40
+
41
+ **Forms** (dispatch matches `Dsl::Reactor::ClassMethods#build_input_validator_for`):
42
+
43
+ | Form | Written as | Compiles to |
44
+ |---|---|---|
45
+ | 0 | `input :x` | declaration only, no rule |
46
+ | 1 | `input :x, :string, min_size?: 2` | `required(:x).filled(:string, min_size?: 2)` |
47
+ | 1b | `input :x, User` | `required(:x).filled(type?: User)` |
48
+ | 1-opt | `input :x, :string, optional: true` | `optional(:x).maybe(:string)` |
49
+ | 2 | `input :x do \|i\| ... end` | block bound to the value macro |
50
+ | 3 | `input :x, validate: Schema` | the supplied schema |
51
+
52
+ ## 2. `validate_inputs` — cross-field rules (step class)
53
+
54
+ ```ruby
55
+ class ChargeStep
56
+ include RubyReactor::Step
57
+
58
+ input :amount, :decimal, gt?: 0
59
+ input :currency, :string
60
+
61
+ validate_inputs do
62
+ required(:amount).filled(:decimal, lt?: 10_000)
63
+ end
64
+ end
65
+ ```
66
+
67
+ Composes with the per-input rules; applied last, wins on conflict — same precedence as the
68
+ reactor's existing `validate_args`.
69
+
70
+ ## 3. `inputs do ... end` — declare a contract (inline step)
71
+
72
+ ```ruby
73
+ step :charge do
74
+ inputs do
75
+ input :amount, :decimal, gt?: 0
76
+ input :currency, :string, included_in?: %w[USD EUR GBP]
77
+
78
+ validate_inputs do
79
+ required(:amount).filled(:decimal, lt?: 10_000)
80
+ end
81
+ end
82
+
83
+ argument :amount, input(:amount) # `input(:x)` here is still the template reference
84
+ argument :currency, input(:currency)
85
+
86
+ run { |args, _| charge!(args) }
87
+ end
88
+ ```
89
+
90
+ The wrapper block exists because inside a `step` block, bare `input(:x)` already means
91
+ "reference the reactor input" (`Dsl::TemplateHelpers#input`). Inside `inputs do`, `input`
92
+ unambiguously declares. The declaration lines are byte-identical to a step class's, so moving
93
+ an inline step into a class is deleting the wrapper.
94
+
95
+ ## 4. `argument` — wiring only
96
+
97
+ ```ruby
98
+ argument(name, source, transform: nil)
99
+ ```
100
+
101
+ Unchanged for dependency resolution and value mapping.
102
+
103
+ | Step owns a contract? | `argument :x, src` | `argument :x, src, :string, gt?: 0` | `validate_args do ... end` |
104
+ |---|---|---|---|
105
+ | Yes | ✅ | ❌ raises at the `step` macro | ❌ raises at the `step` macro |
106
+ | No | ✅ | ✅ + deprecation notice | ✅ + deprecation notice |
107
+
108
+ An `argument` naming an input a contract-owning step does not declare raises at the `step`
109
+ macro.
110
+
111
+ ## 5. Name-based resolution
112
+
113
+ A declared input with no `argument` is satisfied by the reactor input of the same name.
114
+
115
+ ```ruby
116
+ class MyReactor < RubyReactor::Reactor
117
+ input :amount
118
+ input :currency
119
+
120
+ step :charge, ChargeStep # both inputs resolved by name
121
+ end
122
+ ```
123
+
124
+ Rules:
125
+
126
+ - Reactor inputs only — never another step's result.
127
+ - An explicit `argument` always wins and is never overwritten.
128
+ - A required input satisfied by neither raises before execution begins, naming the step, the
129
+ input, and both ways to satisfy it.
130
+
131
+ ## 6. Introspection
132
+
133
+ ```ruby
134
+ ChargeStep.input_contract # => RubyReactor::Step::InputContract
135
+ ChargeStep.declared_inputs # => { amount: InputDeclaration, ... }
136
+ ChargeStep.required_input_names # => [:amount, :currency]
137
+ ChargeStep.declares_inputs? # => true
138
+ ```
139
+
140
+ Read-only. Used by `validate_definition!`, by tooling, and by the dashboard.
141
+
142
+ ## 7. Enforcement points
143
+
144
+ | Entry point | Enforced | Mechanism |
145
+ |---|---|---|
146
+ | Reactor step execution | ✅ | prepended `run` (class) / `args_validator` (inline) |
147
+ | Retry attempt | ✅ | same, re-validated per attempt |
148
+ | `async_step` worker | ✅ | prepended `run` (class); explicit call in `StepWorker#execute_step_body` (inline) |
149
+ | `background` hand-off worker | ✅ | ordinary step execution inside the worker |
150
+ | Resume after interrupt | ✅ | ordinary step execution |
151
+ | Each `map` iteration | ✅ | child reactor's own step execution |
152
+ | `ChargeStep.run(args, ctx)` directly | ✅ | prepended `run` |
153
+ | `compensate` / `undo` | ❌ by design | rollback receives already-validated arguments |
154
+ | Step that a `where`/guard skips | ❌ by design | a step that never runs never validates |
155
+
156
+ ## 8. Errors
157
+
158
+ | Situation | Error | Carries |
159
+ |---|---|---|
160
+ | Contract violated | `RubyReactor::Error::InputValidationError` (raised) | `field_errors`, `step_name`, `step_arguments` |
161
+ | Rules declared in reactor and step class | `RubyReactor::Error::ValidationError` at the `step` macro | reactor, step, argument, owning class |
162
+ | `argument` for an undeclared input | `RubyReactor::Error::ValidationError` at the `step` macro | reactor, step, unknown argument |
163
+ | Required input unwired and unmatched | `RubyReactor::Error::ValidationError` before execution | reactor, step, input, both remedies |
164
+ | `default:` on a required input | `RubyReactor::Error::ValidationError` at the `input` call | input name |
165
+ | Contract declared, dry-validation missing | `LoadError` at declaration | install instruction (existing message) |
166
+
167
+ A raised `InputValidationError` reaches the caller as a `Failure` carrying `validation_errors`,
168
+ after completed steps are rolled back — the existing path in
169
+ `Executor::ResultHandler#handle_execution_error`. `have_validation_error(:field)` matches it
170
+ unchanged.
171
+
172
+ ## 9. Presence semantics
173
+
174
+ A value is "provided" when its key exists, never when it is truthy.
175
+
176
+ | Supplied | Required input | Optional input with `default:` |
177
+ |---|---|---|
178
+ | `false` | ✅ passes, step receives `false` | keeps `false`, default not applied |
179
+ | `0`, `""`, `[]` | ✅ passes | value kept |
180
+ | `nil` | ❌ "must be filled" | default applied |
181
+ | key absent | ❌ "is missing" | default applied |
182
+
183
+ Holds for values sourced from reactor inputs, prior step results, and nested paths within
184
+ either.
185
+
186
+ ## 10. Compatibility
187
+
188
+ - Additive: every existing reactor and step class compiles and behaves identically.
189
+ - `argument` with types/predicates and `validate_args` keep working for steps that declare no
190
+ contract; deprecated in favor of a step-owned contract, removal no earlier than the next
191
+ MAJOR.
192
+ - Falsey-value resolution changes for anyone who relied on `false` arriving as `nil` — a bug
193
+ fix, recorded under Bug Fixes in the changelog.
@@ -0,0 +1,115 @@
1
+ # Phase 1 Data Model: Step Input Contracts
2
+
3
+ **Feature**: `specs/002-step-input-contracts/` | **Date**: 2026-09-10
4
+
5
+ Everything here is definition-time state held on Ruby classes. Nothing new is persisted to
6
+ Redis; resolved argument values continue to round-trip through `ContextSerializer` exactly as
7
+ today.
8
+
9
+ ## InputDeclaration
10
+
11
+ One declared value of one unit of work. Produced by `input` in a step class or inside an
12
+ inline step's `inputs do` block.
13
+
14
+ | Field | Type | Default | Notes |
15
+ |---|---|---|---|
16
+ | `name` | Symbol | — | Required. Unique within a contract; a redeclaration replaces the earlier one. |
17
+ | `type` | Symbol \| Module \| nil | `nil` | `:string`/`:integer`/`:decimal`… → dry-schema positional type. A Module → `type?: Klass` instance check. `nil` → no type constraint. |
18
+ | `optional` | Boolean | `false` | `false` → `required(name).filled(...)`. `true` → `optional(name).maybe(...)`. |
19
+ | `default` | Object \| nil | `nil` | Applied when the key is absent or resolves to `nil`. Never applied for `false` (FR-023). Mutually meaningful only with `optional: true`. |
20
+ | `redact` | Boolean | `false` | Value is masked in failures and logs (FR-015). |
21
+ | `predicates` | Hash | `{}` | dry-schema predicates: `gt?`, `gteq?`, `min_size?`, `max_size?`, `included_in?`, … |
22
+ | `macro_block` | Proc \| nil | `nil` | Form-2 block: `input :x do |i| i.filled(:string) end`. Bound to the value macro. |
23
+ | `schema` | Object \| nil | `nil` | Form-3 pre-built schema/contract via `validate:`. |
24
+
25
+ **Validation rules**: `name` must be a Symbol; exactly one of `predicates`+`type`,
26
+ `macro_block`, or `schema` shapes the rule (matching the reactor's existing `input` dispatch in
27
+ `Dsl::Reactor::ClassMethods#build_input_validator_for`); `default` without `optional: true`
28
+ raises at declaration time.
29
+
30
+ ## InputContract
31
+
32
+ The full set of declarations owned by one unit of work, plus the compiled validator.
33
+
34
+ | Field | Type | Notes |
35
+ |---|---|---|
36
+ | `owner` | Class \| step name | The step class, or the inline step's name. |
37
+ | `declarations` | Ordered Hash{Symbol → InputDeclaration} | Declaration order preserved for message stability. |
38
+ | `cross_field_block` | Proc \| nil | Cross-field rules over the whole argument hash (FR-002). Composed last, wins on conflict — same precedence as today's `validate_args`. |
39
+ | `validator` | `Validation::InputValidator` | Compiled once, lazily, via `SchemaBuilder.build_args(inline_rules, cross_field_block)`. |
40
+
41
+ **Derived queries** (the introspection surface, FR-014):
42
+
43
+ - `required_names` → declarations where `optional == false`
44
+ - `optional_names`, `defaults`, `redacted_names`
45
+ - `declares?(name)`
46
+
47
+ **Inheritance**: a subclass's contract is `parent.declarations.merge(own.declarations)` — same
48
+ name in the subclass replaces the parent's entry (spec Edge Cases). Resolved by walking the
49
+ superclass chain at first access, memoized per class.
50
+
51
+ **State**: `declared` (during class body) → `compiled` (first validation or first
52
+ introspection) → immutable. A declaration added after compilation resets to `declared`;
53
+ supported so reopened classes behave predictably, not an encouraged pattern.
54
+
55
+ ## ArgumentWiring
56
+
57
+ The reactor-side binding. Already exists as the entries of `StepConfig#arguments`; this
58
+ feature narrows its meaning to source + transform only.
59
+
60
+ | Field | Type | Notes |
61
+ |---|---|---|
62
+ | `name` | Symbol | Must match an `InputDeclaration#name` when the step owns a contract (FR-018). |
63
+ | `source` | `Template::Input` \| `Template::Result` \| `Template::Value` \| `Template::Element` | Unchanged. Also carries dependency information for the DAG. |
64
+ | `transform` | Proc \| nil | Unchanged. Applied after resolution, before validation. |
65
+ | `origin` | `:explicit` \| `:inferred` | New. `:inferred` marks a wiring synthesized by the name-based fallback (FR-020), so error messages and the dashboard can say where it came from. |
66
+
67
+ **Rules**: an `:explicit` wiring is never replaced by an `:inferred` one. Rules and types on an
68
+ `argument` are rejected when the step owns a contract (FR-006); still accepted, with a
69
+ deprecation notice, when it does not (FR-010).
70
+
71
+ ## ContractCheckResult
72
+
73
+ Definition-time diagnostics. Not persisted — raised as errors.
74
+
75
+ | Check | When | Raises | FR |
76
+ |---|---|---|---|
77
+ | Rules declared in both places | `step` macro (`StepBuilder#build`) | `Error::ValidationError` naming reactor, step, argument, owning class | FR-006 |
78
+ | `argument` for an undeclared input | `step` macro | `Error::ValidationError` naming the unknown argument | FR-018 |
79
+ | Required input neither wired nor name-matched | `Reactor.validate_definition!` | `Error::ValidationError` naming step, input, and both ways to satisfy it | FR-008, FR-021 |
80
+ | `default` on a required input | `input` call | `Error::ValidationError` | — |
81
+
82
+ ## Validation failure payload
83
+
84
+ Unchanged shape — this feature adds sources, not structures. `build_validation_failure`
85
+ (`executor/result_handler.rb`) already emits:
86
+
87
+ | Field | Source |
88
+ |---|---|
89
+ | `validation_errors` | `InputValidator#format_errors` — flattened `{field => message}` |
90
+ | `step_name` | Stamped by `StepExecutor` for class steps (new, D3); set at the raise site for inline steps (existing) |
91
+ | `reactor_name` | Existing |
92
+ | `step_arguments` | Existing; redacted per `InputDeclaration#redact` |
93
+
94
+ `have_validation_error(:field)` reads `validation_errors` and therefore works against
95
+ contract failures with no matcher change.
96
+
97
+ ## Lifecycle
98
+
99
+ ```text
100
+ class body input :amount, :decimal, gt?: 0 → InputDeclaration
101
+ ──────────────────────────────── appended to InputContract (declared)
102
+
103
+ reactor body step :charge, ChargeStep do
104
+ argument :amount, input(:amount) → ArgumentWiring(:explicit)
105
+ end
106
+ └─ StepBuilder#build ─────────────→ ContractCheckResult (conflict, unknown arg)
107
+
108
+ first execution Reactor.validate_definition! → ContractCheckResult (satisfiability)
109
+ → ArgumentWiring(:inferred) for unwired
110
+ declared inputs matching reactor inputs
111
+
112
+ per step run resolve_arguments → Hash{name => value} (presence-preserving)
113
+ contract.validator.call(args) → Success | raise InputValidationError
114
+ step body → Result
115
+ ```