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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +26 -16
- data/.specify/templates/plan-template.md +4 -0
- data/.specify/templates/tasks-template.md +1 -1
- data/CHANGELOG.md +140 -0
- data/CLAUDE.md +1 -1
- data/README.md +125 -31
- data/lib/ruby_reactor/context.rb +2 -2
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
- 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 +2 -2
- 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/result_handler.rb +9 -3
- data/lib/ruby_reactor/executor/retry_manager.rb +2 -1
- data/lib/ruby_reactor/executor/step_executor.rb +9 -2
- data/lib/ruby_reactor/executor.rb +3 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
- data/lib/ruby_reactor/reactor.rb +9 -12
- data/lib/ruby_reactor/rspec/matchers.rb +3 -6
- 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 +177 -218
- data/lib/ruby_reactor/step.rb +116 -21
- data/lib/ruby_reactor/step_signals.rb +6 -2
- data/lib/ruby_reactor/step_worker.rb +25 -10
- 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.rb +5 -2
- 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 +28 -1
data/lib/ruby_reactor/step.rb
CHANGED
|
@@ -1,42 +1,137 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RubyReactor
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
def
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
26
|
-
|
|
87
|
+
def input(...)
|
|
88
|
+
own_input_contract.input(...)
|
|
89
|
+
@input_contract = nil
|
|
27
90
|
end
|
|
28
|
-
# rubocop:enable Naming/MethodName
|
|
29
91
|
|
|
30
|
-
def
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def own_input_contract
|
|
121
|
+
@own_input_contract ||= Step::InputContract.new(owner: self)
|
|
36
122
|
end
|
|
37
123
|
|
|
38
|
-
def
|
|
39
|
-
|
|
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.
|
|
13
|
-
#
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/ruby_reactor/version.rb
CHANGED
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
|
-
#
|
|
320
|
-
|
|
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
|
+
```
|