ruby_reactor 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
- data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +92 -15
- data/.specify/templates/plan-template.md +4 -0
- data/.specify/templates/tasks-template.md +8 -1
- data/CHANGELOG.md +151 -0
- data/CLAUDE.md +2 -2
- data/README.md +149 -40
- data/lib/ruby_reactor/context.rb +9 -2
- data/lib/ruby_reactor/context_serializer.rb +13 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
- data/lib/ruby_reactor/dsl/lockable.rb +2 -2
- data/lib/ruby_reactor/dsl/reactor.rb +36 -18
- data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
- data/lib/ruby_reactor/dsl/template_helpers.rb +13 -5
- data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
- data/lib/ruby_reactor/executor/result_handler.rb +44 -13
- data/lib/ruby_reactor/executor/retry_manager.rb +6 -2
- data/lib/ruby_reactor/executor/step_executor.rb +27 -14
- data/lib/ruby_reactor/executor.rb +20 -15
- data/lib/ruby_reactor/map/element_executor.rb +7 -1
- data/lib/ruby_reactor/map/helpers.rb +9 -7
- data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
- data/lib/ruby_reactor/map/sweeper.rb +1 -1
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
- data/lib/ruby_reactor/open_telemetry.rb +7 -4
- data/lib/ruby_reactor/ordered_lock.rb +3 -3
- data/lib/ruby_reactor/reactor.rb +9 -12
- data/lib/ruby_reactor/rspec/matchers.rb +64 -17
- data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
- data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
- data/lib/ruby_reactor/step/compose_step.rb +56 -75
- data/lib/ruby_reactor/step/input_contract.rb +128 -0
- data/lib/ruby_reactor/step/map_step.rb +178 -215
- data/lib/ruby_reactor/step.rb +119 -18
- data/lib/ruby_reactor/step_signals.rb +37 -0
- data/lib/ruby_reactor/step_worker.rb +25 -10
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
- data/lib/ruby_reactor/template/result.rb +9 -2
- data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +23 -6
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor.rb +61 -9
- data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
- data/specs/002-step-input-contracts/data-model.md +115 -0
- data/specs/002-step-input-contracts/plan.md +165 -0
- data/specs/002-step-input-contracts/quickstart.md +170 -0
- data/specs/002-step-input-contracts/research.md +233 -0
- data/specs/002-step-input-contracts/spec.md +359 -0
- data/specs/002-step-input-contracts/tasks.md +367 -0
- data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
- data/specs/004-inheritable-step-class/data-model.md +116 -0
- data/specs/004-inheritable-step-class/plan.md +174 -0
- data/specs/004-inheritable-step-class/quickstart.md +112 -0
- data/specs/004-inheritable-step-class/research.md +308 -0
- data/specs/004-inheritable-step-class/spec.md +316 -0
- data/specs/004-inheritable-step-class/tasks.md +258 -0
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
- data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
- data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
- data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
- data/specs/deferred-003-step-lock-declarations/research.md +196 -0
- data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
- data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
- data/specs/possible_feature.md +22 -0
- metadata +34 -11
- data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
- data/specs/001-background-async-steps/checklists/requirements.md +0 -39
- data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
- data/specs/001-background-async-steps/data-model.md +0 -117
- data/specs/001-background-async-steps/plan.md +0 -168
- data/specs/001-background-async-steps/quickstart.md +0 -102
- data/specs/001-background-async-steps/research.md +0 -150
- data/specs/001-background-async-steps/spec.md +0 -146
- data/specs/001-background-async-steps/tasks.md +0 -271
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>ui</title>
|
|
8
|
-
<script type="module" crossorigin src="./assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
8
|
+
<script type="module" crossorigin src="./assets/index-Dw4KV4QY.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="./assets/index-BQvIWPdx.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
data/lib/ruby_reactor.rb
CHANGED
|
@@ -59,6 +59,10 @@ module RubyReactor
|
|
|
59
59
|
false
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
def halted?
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
62
66
|
def to_h
|
|
63
67
|
{ success: true, value: @value }
|
|
64
68
|
end
|
|
@@ -70,7 +74,7 @@ module RubyReactor
|
|
|
70
74
|
# already been claimed. The executor short-circuits before any step
|
|
71
75
|
# runs.
|
|
72
76
|
#
|
|
73
|
-
# 2. Explicitly, by returning `RubyReactor.
|
|
77
|
+
# 2. Explicitly, by returning `RubyReactor.Halt(reason: "...")` from a
|
|
74
78
|
# step's `run` block. The reactor halts immediately — no further steps,
|
|
75
79
|
# and crucially **no compensation** of already-completed steps. Use this
|
|
76
80
|
# when a step discovers that the rest of the workflow is not needed
|
|
@@ -78,8 +82,10 @@ module RubyReactor
|
|
|
78
82
|
# partial progress is still correct to keep.
|
|
79
83
|
#
|
|
80
84
|
# Subclass of Success so callers that only check `success?` continue to work;
|
|
81
|
-
# `
|
|
82
|
-
|
|
85
|
+
# `halted?` distinguishes it. Deliberately does NOT define `skipped?` — a
|
|
86
|
+
# migration site that still asks a halt whether it was skipped should raise
|
|
87
|
+
# NoMethodError, not silently read false.
|
|
88
|
+
class Halt < Success
|
|
83
89
|
attr_reader :reason, :period_key, :step_name
|
|
84
90
|
|
|
85
91
|
def initialize(reason: nil, period_key: nil, step_name: nil)
|
|
@@ -89,6 +95,37 @@ module RubyReactor
|
|
|
89
95
|
@step_name = step_name
|
|
90
96
|
end
|
|
91
97
|
|
|
98
|
+
def halted?
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
undef_method :skipped?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Marks a single step as skipped while the reactor continues. Behaves
|
|
106
|
+
# exactly like Success — the value flows to dependants via `result(:step)` —
|
|
107
|
+
# except `skipped?` is true and the step is not enrolled for rollback
|
|
108
|
+
# (nothing happened, so there is nothing to undo).
|
|
109
|
+
class Skipped < Success
|
|
110
|
+
# Sentinel distinguishing "no value argument given" (the old Halt call
|
|
111
|
+
# shape reused this class's name) from an explicit `Skipped(nil)`.
|
|
112
|
+
UNSET = Object.new.freeze
|
|
113
|
+
private_constant :UNSET
|
|
114
|
+
|
|
115
|
+
attr_reader :reason, :step_name
|
|
116
|
+
|
|
117
|
+
def initialize(value = UNSET, reason: nil, step_name: nil)
|
|
118
|
+
if value.equal?(UNSET) && !reason.nil?
|
|
119
|
+
raise ArgumentError,
|
|
120
|
+
"RubyReactor::Skipped now marks a single step as skipped and continues. " \
|
|
121
|
+
"The clean halt you want is RubyReactor.Halt(reason: ...) / halt!(reason: ...)."
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
super(value.equal?(UNSET) ? nil : value)
|
|
125
|
+
@reason = reason
|
|
126
|
+
@step_name = step_name
|
|
127
|
+
end
|
|
128
|
+
|
|
92
129
|
def skipped?
|
|
93
130
|
true
|
|
94
131
|
end
|
|
@@ -101,8 +138,10 @@ module RubyReactor
|
|
|
101
138
|
# rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
102
139
|
def initialize(error, retryable: nil, step_name: nil, inputs: {}, backtrace: nil, redact_inputs: [],
|
|
103
140
|
reactor_name: nil, step_arguments: {}, exception_class: nil,
|
|
104
|
-
file_path: nil, line_number: nil, code_snippet: nil, invalid_payload: false, validation_errors: nil
|
|
141
|
+
file_path: nil, line_number: nil, code_snippet: nil, invalid_payload: false, validation_errors: nil,
|
|
142
|
+
**opts)
|
|
105
143
|
# rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
144
|
+
retryable = opts[:retry] if opts.key?(:retry) # `retry:` wins over `retryable:` when both are given
|
|
106
145
|
@error = error
|
|
107
146
|
|
|
108
147
|
# Handle case where error is a serialized hash (e.g. from async failure propagation)
|
|
@@ -158,6 +197,10 @@ module RubyReactor
|
|
|
158
197
|
false
|
|
159
198
|
end
|
|
160
199
|
|
|
200
|
+
def halted?
|
|
201
|
+
false
|
|
202
|
+
end
|
|
203
|
+
|
|
161
204
|
def invalid_payload?
|
|
162
205
|
@invalid_payload
|
|
163
206
|
end
|
|
@@ -182,6 +225,7 @@ module RubyReactor
|
|
|
182
225
|
{
|
|
183
226
|
success: false,
|
|
184
227
|
error: error_message,
|
|
228
|
+
retryable: @retryable,
|
|
185
229
|
step_name: @step_name,
|
|
186
230
|
inputs: @inputs,
|
|
187
231
|
redact_inputs: @redact_inputs,
|
|
@@ -273,8 +317,10 @@ module RubyReactor
|
|
|
273
317
|
end
|
|
274
318
|
|
|
275
319
|
def extract_attributes_from_hash(error_hash)
|
|
276
|
-
#
|
|
277
|
-
|
|
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) }
|
|
278
324
|
|
|
279
325
|
{
|
|
280
326
|
error: err[:message] || err[:error] || error_hash,
|
|
@@ -327,10 +373,16 @@ module RubyReactor
|
|
|
327
373
|
Failure.new(error, **kwargs)
|
|
328
374
|
end
|
|
329
375
|
|
|
330
|
-
# Build a `
|
|
376
|
+
# Build a `Halt` result. Return one from a step's `run` block to halt the
|
|
331
377
|
# reactor cleanly without triggering compensation of previous steps.
|
|
332
|
-
def self.
|
|
333
|
-
|
|
378
|
+
def self.Halt(reason: nil, **kwargs)
|
|
379
|
+
Halt.new(reason: reason, **kwargs)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Build a `Skipped` result. Return one from a step's `run` block to mark
|
|
383
|
+
# that single step skipped while the reactor continues.
|
|
384
|
+
def self.Skipped(...)
|
|
385
|
+
Skipped.new(...)
|
|
334
386
|
end
|
|
335
387
|
|
|
336
388
|
def self.configure
|
|
@@ -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
|
+
```
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Implementation Plan: Step Input Contracts
|
|
2
|
+
|
|
3
|
+
**Branch**: `step_validations` | **Date**: 2026-09-10 | **Spec**: [spec.md](./spec.md)
|
|
4
|
+
|
|
5
|
+
**Input**: Feature specification from `specs/002-step-input-contracts/spec.md`
|
|
6
|
+
|
|
7
|
+
## Summary
|
|
8
|
+
|
|
9
|
+
Move validation out of the reactor and into the unit of work. A step class declares its own
|
|
10
|
+
inputs with `input :name, :type, **predicates`; an inline step declares the same lines inside
|
|
11
|
+
an `inputs do ... end` block. The reactor's `argument` keeps dependency wiring and value
|
|
12
|
+
mapping and loses rule declaration for any step that owns a contract — attempting both fails
|
|
13
|
+
at the `step` macro rather than producing two overlapping rule sets at run time.
|
|
14
|
+
|
|
15
|
+
Enforcement for class steps lives in a `run` wrapper prepended onto the step's singleton
|
|
16
|
+
class, which covers the executor, the async worker, and direct invocation with one mechanism
|
|
17
|
+
and reuses the library's existing `Error::InputValidationError` protocol (raise → rollback →
|
|
18
|
+
`build_validation_failure` → `have_validation_error`). Inline contracts compile to the
|
|
19
|
+
existing `args_validator` and close the async worker's missing validation call.
|
|
20
|
+
|
|
21
|
+
Two supporting corrections ship with it: an unwired declared input resolves from a same-named
|
|
22
|
+
reactor input (checked before execution, never from another step's result), and falsey values
|
|
23
|
+
stop being lost in transit — `Context#get_input`/`#get_result` and `Template::Result#fetch`
|
|
24
|
+
currently turn a supplied `false` into `nil`, which contracts would escalate into a spurious
|
|
25
|
+
"must be filled" failure.
|
|
26
|
+
|
|
27
|
+
Design decisions and the evidence behind them: [research.md](./research.md).
|
|
28
|
+
|
|
29
|
+
## Technical Context
|
|
30
|
+
|
|
31
|
+
**Language/Version**: Ruby >= 3.0.0
|
|
32
|
+
|
|
33
|
+
**Primary Dependencies**: dry-validation ~> 1.10 (schema construction; already a hard gem
|
|
34
|
+
dependency), sidekiq ~> 7.0 (async step path), redis ~> 5.0, zeitwerk ~> 2.6
|
|
35
|
+
|
|
36
|
+
**Storage**: Redis (unchanged by this feature — contracts are compile-time declarations;
|
|
37
|
+
resolved arguments already round-trip through `ContextSerializer`)
|
|
38
|
+
|
|
39
|
+
**Testing**: RSpec with real Redis (constitution III). Feature specs under
|
|
40
|
+
`spec/ruby_reactor/dsl/` and `spec/ruby_reactor/`; acceptance via `demo_app/` rake tasks run
|
|
41
|
+
through `docker compose run`.
|
|
42
|
+
|
|
43
|
+
**Target Platform**: Ruby library (gem), sync and Sidekiq-backed async execution
|
|
44
|
+
|
|
45
|
+
**Project Type**: Library / DSL
|
|
46
|
+
|
|
47
|
+
**Performance Goals**: Validation cost is per-step-execution and already paid today for
|
|
48
|
+
reactor-declared rules; no measurable regression. Contract compilation happens once at class
|
|
49
|
+
definition. `validate_definition!` is memoized per reactor class.
|
|
50
|
+
|
|
51
|
+
**Constraints**: Public API is SemVer-governed — additive DSL (MINOR); no existing reactor may
|
|
52
|
+
change behavior except the falsey-value fix (PATCH-class bug fix, changelog note required).
|
|
53
|
+
dry-validation stays the only validation engine.
|
|
54
|
+
|
|
55
|
+
**Scale/Scope**: ~8 library files touched, 1 new file, plus demo-app artifacts and README.
|
|
56
|
+
No new gem dependency.
|
|
57
|
+
|
|
58
|
+
## Constitution Check
|
|
59
|
+
|
|
60
|
+
*GATE: passed before Phase 0. Re-checked after Phase 1 design — see bottom of this section.*
|
|
61
|
+
|
|
62
|
+
| Principle | Assessment |
|
|
63
|
+
|---|---|
|
|
64
|
+
| **I. Gem-First Design** | ✅ Entirely inside `lib/`. No host coupling, no monkey-patching. The Sidekiq-facing change (`StepWorker`) is inside the existing adapter boundary. |
|
|
65
|
+
| **II. Saga Pattern Integrity** | ✅ Validation failures raise `Error::InputValidationError`, which `handle_execution_error` already routes through `rollback_completed_steps` before building the failure. Compensation semantics are inherited, not re-implemented. Validation runs *before* the step body, so a rejected step produces no side effect to compensate. |
|
|
66
|
+
| **III. Test-First with Real Infrastructure** | ✅ Red-Green-Refactor per task. The async-worker validation gap (research Finding 2) is exercised against real Redis + Sidekiq, not `Sidekiq::Testing.inline!`. |
|
|
67
|
+
| **IV. Observability by Default** | ✅ Failures carry reactor name, step name, redacted inputs, and field errors — `build_validation_failure` already assembles this; D3 adds the missing step attribution for class steps. FR-015 keeps `redact:` declarable on a step's own contract. |
|
|
68
|
+
| **V. Simplicity and SemVer** | ✅ MINOR: `input` on step classes and `inputs do` in step blocks are additive; reactor-declared rules keep working (D9 deprecation, not removal). The conflict error (D5) can only fire on code written after this release, since declaring a contract on a step class is not possible today. Falsey fix is a bug fix with a changelog note. Two enforcement mechanisms are justified in Complexity Tracking. |
|
|
69
|
+
| **VI. Demo-App Proof of Feature** | ✅ Planned as blocking work, not follow-up: example reactor + `demo:` rake task + spec using only the shipped matchers + `docker compose run` acceptance. `have_validation_error` already exists, so no matcher extension is expected — if a task finds an assertion it cannot express, the matcher is added to `lib/ruby_reactor/rspec/` in the same change. |
|
|
70
|
+
|
|
71
|
+
**Post-design re-check**: no new violations. The design adds no new dependency, no new
|
|
72
|
+
storage primitive, and no new failure shape — it routes a new declaration site into the
|
|
73
|
+
error protocol the library already has. The one item carried to Complexity Tracking is the
|
|
74
|
+
dual enforcement mechanism.
|
|
75
|
+
|
|
76
|
+
## Project Structure
|
|
77
|
+
|
|
78
|
+
### Documentation (this feature)
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
specs/002-step-input-contracts/
|
|
82
|
+
├── plan.md # This file
|
|
83
|
+
├── spec.md # Feature specification
|
|
84
|
+
├── research.md # Phase 0 — current-state findings and design decisions
|
|
85
|
+
├── data-model.md # Phase 1 — contract/declaration entities and lifecycle
|
|
86
|
+
├── quickstart.md # Phase 1 — how to run and verify the feature
|
|
87
|
+
├── contracts/
|
|
88
|
+
│ └── dsl-surface.md # Phase 1 — public DSL, errors, introspection API
|
|
89
|
+
├── checklists/
|
|
90
|
+
│ └── requirements.md # Spec quality checklist (complete)
|
|
91
|
+
└── tasks.md # Phase 2 — /speckit-tasks output, NOT created here
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Source Code (repository root)
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
lib/ruby_reactor/
|
|
98
|
+
├── step.rb # + `input` DSL, contract storage/inheritance,
|
|
99
|
+
│ # introspection, singleton `run` wrapper (D1, D3)
|
|
100
|
+
├── step/
|
|
101
|
+
│ └── input_contract.rb # NEW — declaration list, schema compilation,
|
|
102
|
+
│ # defaults, redaction, inheritance merge
|
|
103
|
+
├── dsl/
|
|
104
|
+
│ ├── step_builder.rb # + `inputs do` block (D2); conflict + unknown-argument
|
|
105
|
+
│ │ # errors at build time (D5)
|
|
106
|
+
│ ├── reactor.rb # + `validate_definition!` (D6), name-based
|
|
107
|
+
│ │ # fallback wiring (D7)
|
|
108
|
+
│ └── validation_helpers.rb # reused unchanged by the step-side builder
|
|
109
|
+
├── executor/
|
|
110
|
+
│ └── step_executor.rb # stamp `step_name` on re-raised validation errors (D3)
|
|
111
|
+
├── step_worker.rb # validate inline-step args; InputValidationError branch (D4)
|
|
112
|
+
├── context.rb # presence-aware get_input / get_result (D8)
|
|
113
|
+
├── template/result.rb # presence-aware nested fetch (D8)
|
|
114
|
+
├── utils/
|
|
115
|
+
│ └── fetch_indifferent.rb # NEW — the one shared presence-aware lookup (D8)
|
|
116
|
+
├── reactor.rb # call validate_definition! before execution (D6)
|
|
117
|
+
└── rspec/
|
|
118
|
+
└── test_subject.rb # call validate_definition! from test_reactor (D6)
|
|
119
|
+
|
|
120
|
+
spec/ruby_reactor/
|
|
121
|
+
├── dsl/step_input_contract_spec.rb # NEW — declaration, inheritance, introspection
|
|
122
|
+
├── dsl/step_contract_conflict_spec.rb # NEW — D5 errors, D6 satisfiability
|
|
123
|
+
├── step_contract_enforcement_spec.rb # NEW — all execution paths incl. async worker
|
|
124
|
+
└── falsey_input_resolution_spec.rb # NEW — FR-023 across inputs, results, paths
|
|
125
|
+
|
|
126
|
+
demo_app/
|
|
127
|
+
├── app/reactors/validated_signup_reactor.rb # NEW — contract-owning step, pass + fail path
|
|
128
|
+
├── lib/tasks/demo_reactors.rake # + demo:validated_signup
|
|
129
|
+
└── spec/reactors/validated_signup_reactor_spec.rb # NEW — shipped matchers only
|
|
130
|
+
|
|
131
|
+
README.md # class-step form primary, inline equivalent, migration
|
|
132
|
+
CHANGELOG.md # Features + Bug Fixes entries
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Structure Decision**: The gem's existing layout is kept as-is. Two new library files only —
|
|
136
|
+
`step/input_contract.rb` (the declaration object the spec's Key Entities describe) and
|
|
137
|
+
`utils/fetch_indifferent.rb` (one helper, three call sites). Everything else is an edit to the
|
|
138
|
+
file that already owns the concern.
|
|
139
|
+
|
|
140
|
+
## Phase 2 outline (for `/speckit-tasks`)
|
|
141
|
+
|
|
142
|
+
Dependency-ordered, each block independently testable:
|
|
143
|
+
|
|
144
|
+
1. **Falsey resolution (FR-023)** — `fetch_indifferent` + three call sites + spec. Independent
|
|
145
|
+
of everything else; land first so contract work builds on correct presence semantics.
|
|
146
|
+
2. **Contract declaration (US1, FR-001/002/013/014/015)** — `InputContract`, `Step#input`,
|
|
147
|
+
inheritance, introspection. No enforcement yet.
|
|
148
|
+
3. **Enforcement (US1, FR-003/004/022)** — prepended `run` wrapper, step-name stamping,
|
|
149
|
+
worker path. Covers class steps on every execution path.
|
|
150
|
+
4. **Reactor-side split (US2, FR-005/006/018)** — conflict and unknown-argument errors at the
|
|
151
|
+
`step` macro.
|
|
152
|
+
5. **Inline contracts (US3, FR-007)** — `inputs do` block → `args_validator`, worker
|
|
153
|
+
validation call, equivalence spec against the class form.
|
|
154
|
+
6. **Wiring resolution (US4, FR-008/020/021)** — `validate_definition!`, name-based fallback,
|
|
155
|
+
invocation from `run`/`call`/`test_reactor`.
|
|
156
|
+
7. **Back-compat + deprecation (US5, FR-010/011)** — existing-suite green, one-time notices.
|
|
157
|
+
8. **Docs + demo (FR-016/017)** — README, CHANGELOG, demo reactor + rake + spec, docker
|
|
158
|
+
acceptance run.
|
|
159
|
+
|
|
160
|
+
## Complexity Tracking
|
|
161
|
+
|
|
162
|
+
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
|
163
|
+
|-----------|------------|--------------------------------------|
|
|
164
|
+
| Two enforcement mechanisms — prepended `run` for class steps (D3), `args_validator` for inline steps (D4) | An inline step has no class to prepend to, and a class step must validate on paths the reactor does not control (`StepWorker#execute_step_body`, direct invocation). | Putting every contract into `args_validator` leaves the async worker path and direct invocation unvalidated (research Finding 2), and re-centralizes in the reactor exactly what this feature moves into the step. Both mechanisms raise the same error class through the same handler, so the observable behavior is one protocol, and an equivalence spec pins them together. |
|
|
165
|
+
| `validate_definition!` runs at first execution rather than at class load (D6) | The satisfiability check needs the reactor's complete input list, which is not available while the class body is still executing, and there is no registry of user reactor classes to sweep at boot (research Finding 7). | `TracePoint(:end)` to detect the end of a class body is unreadable and breaks on reopened classes; requiring `input` before `step` silently breaks valid existing reactors. The user-visible property is preserved — the reactor fails before step one runs, not on the run that first reaches the step. |
|