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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
  3. data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
  4. data/.release-please-manifest.json +1 -1
  5. data/.specify/feature.json +1 -1
  6. data/.specify/memory/constitution.md +92 -15
  7. data/.specify/templates/plan-template.md +4 -0
  8. data/.specify/templates/tasks-template.md +8 -1
  9. data/CHANGELOG.md +151 -0
  10. data/CLAUDE.md +2 -2
  11. data/README.md +149 -40
  12. data/lib/ruby_reactor/context.rb +9 -2
  13. data/lib/ruby_reactor/context_serializer.rb +13 -0
  14. data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
  15. data/lib/ruby_reactor/dsl/lockable.rb +2 -2
  16. data/lib/ruby_reactor/dsl/reactor.rb +36 -18
  17. data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
  18. data/lib/ruby_reactor/dsl/template_helpers.rb +13 -5
  19. data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
  20. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  21. data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
  22. data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
  23. data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
  24. data/lib/ruby_reactor/executor/result_handler.rb +44 -13
  25. data/lib/ruby_reactor/executor/retry_manager.rb +6 -2
  26. data/lib/ruby_reactor/executor/step_executor.rb +27 -14
  27. data/lib/ruby_reactor/executor.rb +20 -15
  28. data/lib/ruby_reactor/map/element_executor.rb +7 -1
  29. data/lib/ruby_reactor/map/helpers.rb +9 -7
  30. data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
  31. data/lib/ruby_reactor/map/sweeper.rb +1 -1
  32. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
  33. data/lib/ruby_reactor/open_telemetry.rb +7 -4
  34. data/lib/ruby_reactor/ordered_lock.rb +3 -3
  35. data/lib/ruby_reactor/reactor.rb +9 -12
  36. data/lib/ruby_reactor/rspec/matchers.rb +64 -17
  37. data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
  38. data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
  39. data/lib/ruby_reactor/step/compose_step.rb +56 -75
  40. data/lib/ruby_reactor/step/input_contract.rb +128 -0
  41. data/lib/ruby_reactor/step/map_step.rb +178 -215
  42. data/lib/ruby_reactor/step.rb +119 -18
  43. data/lib/ruby_reactor/step_signals.rb +37 -0
  44. data/lib/ruby_reactor/step_worker.rb +25 -10
  45. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  46. data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
  47. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
  48. data/lib/ruby_reactor/template/result.rb +9 -2
  49. data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
  50. data/lib/ruby_reactor/version.rb +1 -1
  51. data/lib/ruby_reactor/web/api.rb +23 -6
  52. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
  53. data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
  54. data/lib/ruby_reactor/web/public/index.html +2 -2
  55. data/lib/ruby_reactor.rb +61 -9
  56. data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
  57. data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
  58. data/specs/002-step-input-contracts/data-model.md +115 -0
  59. data/specs/002-step-input-contracts/plan.md +165 -0
  60. data/specs/002-step-input-contracts/quickstart.md +170 -0
  61. data/specs/002-step-input-contracts/research.md +233 -0
  62. data/specs/002-step-input-contracts/spec.md +359 -0
  63. data/specs/002-step-input-contracts/tasks.md +367 -0
  64. data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
  65. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
  66. data/specs/004-inheritable-step-class/data-model.md +116 -0
  67. data/specs/004-inheritable-step-class/plan.md +174 -0
  68. data/specs/004-inheritable-step-class/quickstart.md +112 -0
  69. data/specs/004-inheritable-step-class/research.md +308 -0
  70. data/specs/004-inheritable-step-class/spec.md +316 -0
  71. data/specs/004-inheritable-step-class/tasks.md +258 -0
  72. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
  73. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
  74. data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
  75. data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
  76. data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
  77. data/specs/deferred-003-step-lock-declarations/research.md +196 -0
  78. data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
  79. data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
  80. data/specs/possible_feature.md +22 -0
  81. metadata +34 -11
  82. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
  83. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
  84. data/specs/001-background-async-steps/checklists/requirements.md +0 -39
  85. data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
  86. data/specs/001-background-async-steps/data-model.md +0 -117
  87. data/specs/001-background-async-steps/plan.md +0 -168
  88. data/specs/001-background-async-steps/quickstart.md +0 -102
  89. data/specs/001-background-async-steps/research.md +0 -150
  90. data/specs/001-background-async-steps/spec.md +0 -146
  91. data/specs/001-background-async-steps/tasks.md +0 -271
@@ -0,0 +1,170 @@
1
+ # Quickstart: Step Input Contracts
2
+
3
+ **Feature**: `specs/002-step-input-contracts/` | **Date**: 2026-09-10
4
+
5
+ How to run and verify this feature end to end. DSL details live in
6
+ [contracts/dsl-surface.md](./contracts/dsl-surface.md); design rationale in
7
+ [research.md](./research.md).
8
+
9
+ ## Prerequisites
10
+
11
+ - Ruby >= 3.0, `bundle install`
12
+ - Docker (for the gem's test Redis on port 6780 and the demo app's Redis on 6380)
13
+
14
+ ```bash
15
+ docker compose up -d redis-test # required: spec_helper aborts without it
16
+ ```
17
+
18
+ ## Scenario 1 — a step class owns its contract (US1)
19
+
20
+ ```ruby
21
+ class ValidatedUserStep
22
+ include RubyReactor::Step
23
+
24
+ input :name, :string, min_size?: 2
25
+ input :email, :string
26
+ input :age, :integer, gteq?: 18
27
+ input :bio, :string, optional: true, default: "No bio provided", max_size?: 100
28
+
29
+ def self.run(args, _context)
30
+ Success(args.merge(created_at: Time.now))
31
+ end
32
+ end
33
+
34
+ class SignupReactor < RubyReactor::Reactor
35
+ input :name
36
+ input :email
37
+ input :age
38
+
39
+ step :profile, ValidatedUserStep # no argument block — resolved by name
40
+ returns :profile
41
+ end
42
+ ```
43
+
44
+ **Expected**
45
+
46
+ ```ruby
47
+ SignupReactor.run(name: "Ada", email: "ada@example.com", age: 36)
48
+ # => Success, bio defaulted to "No bio provided"
49
+
50
+ SignupReactor.run(name: "A", email: "ada@example.com", age: 17)
51
+ # => Failure; validation_errors has :name and :age; ValidatedUserStep.run never called
52
+ ```
53
+
54
+ **Verify**
55
+
56
+ ```bash
57
+ bundle exec rspec spec/ruby_reactor/dsl/step_input_contract_spec.rb
58
+ bundle exec rspec spec/ruby_reactor/step_contract_enforcement_spec.rb
59
+ ```
60
+
61
+ ## Scenario 2 — the reactor may not redeclare rules (US2)
62
+
63
+ ```ruby
64
+ class BadReactor < RubyReactor::Reactor
65
+ input :age
66
+ step :profile, ValidatedUserStep do
67
+ argument :age, input(:age), :integer, gteq?: 21 # ← rules on a contract-owning step
68
+ end
69
+ end
70
+ # raises RubyReactor::Error::ValidationError when the class body runs,
71
+ # naming BadReactor, :profile, :age, and ValidatedUserStep
72
+ ```
73
+
74
+ Same for `validate_args do ... end`, and for an `argument` naming an input the step never
75
+ declares.
76
+
77
+ **Verify**: `bundle exec rspec spec/ruby_reactor/dsl/step_contract_conflict_spec.rb`
78
+
79
+ ## Scenario 3 — inline steps, same vocabulary (US3)
80
+
81
+ ```ruby
82
+ step :charge do
83
+ inputs do
84
+ input :amount, :decimal, gt?: 0
85
+ end
86
+ argument :amount, input(:amount)
87
+ run { |args, _| Success(charge!(args[:amount])) }
88
+ end
89
+ ```
90
+
91
+ **Expected**: identical outcomes to the same declarations in a step class, for both
92
+ conforming and violating values (SC-005). The equivalence spec asserts this pair directly.
93
+
94
+ ## Scenario 4 — missing wiring is caught before execution (US4)
95
+
96
+ ```ruby
97
+ class IncompleteReactor < RubyReactor::Reactor
98
+ input :name # :email and :age never declared or wired
99
+ step :profile, ValidatedUserStep
100
+ end
101
+
102
+ IncompleteReactor.run(name: "Ada")
103
+ # => RubyReactor::Error::ValidationError naming :profile, :email, and how to satisfy it.
104
+ # ValidatedUserStep.run is never called; no step in the reactor runs.
105
+ ```
106
+
107
+ Callable directly for a boot-time or CI check:
108
+
109
+ ```ruby
110
+ IncompleteReactor.validate_definition!
111
+ ```
112
+
113
+ ## Scenario 5 — falsey values survive (FR-023)
114
+
115
+ ```ruby
116
+ class NotifyStep
117
+ include RubyReactor::Step
118
+ input :notify, :bool
119
+ def self.run(args, _ctx) = Success(args[:notify])
120
+ end
121
+
122
+ SomeReactor.run(notify: false)
123
+ # => Success(false) — not a "must be filled" failure, and not nil
124
+ ```
125
+
126
+ Covers reactor inputs, prior step results, and nested paths through either.
127
+
128
+ **Verify**: `bundle exec rspec spec/ruby_reactor/falsey_input_resolution_spec.rb`
129
+
130
+ ## Scenario 6 — every execution path (FR-003)
131
+
132
+ The async worker is the path that has no argument validation today
133
+ ([research.md](./research.md) Finding 2), so it is the one worth running against real
134
+ infrastructure rather than `Sidekiq::Testing.inline!`:
135
+
136
+ ```bash
137
+ docker compose up -d demo-redis sidekiq
138
+ docker compose run --rm demo-app bin/rails demo:validated_signup
139
+ ```
140
+
141
+ **Expected output**: the passing run prints the created profile; the failing run prints a
142
+ validation failure naming the step and the offending fields; the `async_step` variant shows
143
+ the same failure produced inside the worker.
144
+
145
+ ## Full suite
146
+
147
+ ```bash
148
+ docker compose up -d redis-test
149
+ bundle exec rspec # gem suite
150
+ bundle exec rubocop # required by the constitution, no --disable-pending-cops
151
+
152
+ docker compose run --rm demo-app bundle exec rspec spec/reactors/validated_signup_reactor_spec.rb
153
+ docker compose run --rm demo-app bin/rails demo:validated_signup
154
+ ```
155
+
156
+ ## Acceptance checklist
157
+
158
+ | # | Claim | How it is verified |
159
+ |---|---|---|
160
+ | SC-001 | Contract readable from the step class alone | No demo reactor declares a rule for a contract-owning step |
161
+ | SC-002 | Same step, same rules in every reactor | Two reactors reuse `ValidatedUserStep`, zero per-reactor rules |
162
+ | SC-003 | Conflicts reported at definition | Scenario 2 raises when the class body runs |
163
+ | SC-004 | Unwired required inputs reported before execution | Scenario 4 |
164
+ | SC-005 | Inline ↔ class equivalence | Scenario 3 equivalence spec |
165
+ | SC-006 | Existing behavior preserved | Full gem suite green |
166
+ | SC-007 | Failure names reactor, step, fields | `have_validation_error` + failure payload assertions |
167
+ | SC-008 | Demo runs in docker, both paths | Scenario 6 |
168
+ | SC-009 | Name-matched reactors need no arguments | Scenario 1 has no argument block |
169
+ | SC-010 | Direct call ≡ reactor call | `ValidatedUserStep.run({age: 17}, ctx)` raises the same error |
170
+ | SC-011 | `false` arrives as `false` | Scenario 5 |
@@ -0,0 +1,233 @@
1
+ # Phase 0 Research: Step Input Contracts
2
+
3
+ **Feature**: `specs/002-step-input-contracts/` | **Date**: 2026-09-10
4
+
5
+ All findings below come from reading the current implementation, not from assumption. File
6
+ references are to the state of `step_validations` at the time of writing.
7
+
8
+ ## Current state (what exists today)
9
+
10
+ | Concern | Where it lives now |
11
+ |---|---|
12
+ | Step argument wiring + rules | `Dsl::StepBuilder#argument` (`lib/ruby_reactor/dsl/step_builder.rb:41`) — one call does source mapping, transform, type, and predicates |
13
+ | Cross-field rules | `Dsl::StepBuilder#validate_args` (`step_builder.rb:76`) |
14
+ | Schema construction | `Validation::SchemaBuilder` (`build_inline`, `build_args`, `apply_inline_rules`) |
15
+ | Enforcement (inline path) | `Executor::StepExecutor#validate_step_arguments` (`step_executor.rb:312`) — **raises** `Error::InputValidationError` |
16
+ | Failure shaping | `Executor::ResultHandler#handle_execution_error` (`result_handler.rb:39`) — rollback + `build_validation_failure` |
17
+ | Step classes | `RubyReactor::Step` (`lib/ruby_reactor/step.rb`) — 43 lines: result helpers plus `run`/`compensate`/`undo` stubs. **No declaration DSL at all.** |
18
+
19
+ ### Finding 1 — class steps have no contract surface and no implicit inputs
20
+
21
+ `resolve_arguments` builds only from `step_config.arguments`. `run_step_implementation`
22
+ (`step_executor.rb:346-353`) falls back to `@context.inputs` **only when the step has a run
23
+ block**. A class step with no `argument` declarations receives `{}`. FR-020's name-based
24
+ fallback is therefore new behavior for class steps, not a preserved one.
25
+
26
+ ### Finding 2 — there are two step execution paths, and only one validates
27
+
28
+ - Inline/retry/resume: `execute_step` → `execute_step_with_retry` → `safe_execute_step_sync`
29
+ → `execute_step_sync_without_result_handling` → `validate_step_arguments`. ✅
30
+ - `async_step` worker: `StepWorker#execute_step_body` (`step_worker.rb:112-118`) calls
31
+ `step_config.run_block.call` / `step_config.impl.run` directly. **No validation.** ❌
32
+
33
+ FR-003 ("enforced on every execution path") is not satisfiable by adding rules to
34
+ `step_config` alone.
35
+
36
+ ### Finding 3 — raising is the established validation protocol
37
+
38
+ `safe_execute_step_sync` (`step_executor.rb:186`) explicitly re-raises
39
+ `Error::InputValidationError` so it is never retried and never wrapped as a generic step
40
+ failure. `handle_execution_error` then rolls back completed steps and calls
41
+ `build_validation_failure`, producing the `validation_errors` payload that
42
+ `have_validation_error` (`rspec/matchers.rb:154`) reads. Any new enforcement point that
43
+ raises this error inherits the correct failure shape, saga rollback, retry suppression, and
44
+ matcher support for free.
45
+
46
+ ### Finding 4 — `input` is already overloaded, and collides inside step blocks
47
+
48
+ - On a reactor body, `Dsl::Reactor::ClassMethods#input` (`dsl/reactor.rb:70`) **declares**.
49
+ - Inside `step ... do ... end`, `StepBuilder` includes `Dsl::TemplateHelpers`, whose
50
+ `input(name, path = nil)` (`template_helpers.rb:8`) **returns a `Template::Input`** used as
51
+ an argument source.
52
+
53
+ So `input` means "declare" in one scope and "reference" in an adjacent one. Any inline-step
54
+ contract syntax must not make that worse. This is the single biggest design constraint on
55
+ FR-007/FR-012.
56
+
57
+ ### Finding 5 — falsey values are lost before a step sees them
58
+
59
+ `Context#get_input` (`context.rb:67`) and `#get_result` (`context.rb:79`) both use
60
+ `@inputs[name.to_sym] || @inputs[name.to_s]`; `Template::Result#fetch`
61
+ (`template/result.rb:178`) repeats the pattern for nested lookups. A supplied `false`
62
+ resolves to `nil`. Under contracts this escalates from a silent wrong value into a spurious
63
+ "must be filled" failure (FR-023).
64
+
65
+ ### Finding 6 — internal steps include `RubyReactor::Step`
66
+
67
+ `MapStep`, `ComposeStep`, and `AsyncReactorStep` all `include RubyReactor::Step`. Anything
68
+ added to that module must be inert for a step that declares no contract.
69
+
70
+ ### Finding 7 — no registry of user reactor classes
71
+
72
+ `Registry` (`lib/ruby_reactor/registry.rb`) holds only dynamically-generated reactors from
73
+ inline `map`/`compose`. There is no list of all user-defined reactor classes, so there is no
74
+ place to hang a global "validate every reactor at boot" pass without adding one.
75
+
76
+ ---
77
+
78
+ ## Decisions
79
+
80
+ ### D1 — A contract is declared with `input` on the step class
81
+
82
+ ```ruby
83
+ class ChargeStep
84
+ include RubyReactor::Step
85
+
86
+ input :amount, :decimal, gt?: 0
87
+ input :currency, :string, included_in?: %w[USD EUR GBP]
88
+ input :user, User
89
+ input :note, :string, optional: true, max_size?: 100
90
+
91
+ def self.run(args, context) = Success(charge!(args))
92
+ end
93
+ ```
94
+
95
+ **Rationale**: matches the user's sketch and the reactor's own `input`. No collision exists in
96
+ a step class body — `Step` does not include `TemplateHelpers`.
97
+
98
+ **Signature** is deliberately identical to `Dsl::Reactor::ClassMethods#input`:
99
+ `input(name, type = nil, optional: false, default: nil, redact: false, **predicates, &block)`,
100
+ including the Form-2 macro block (`input :x do |i| ... end`) and `validate:` for a pre-built
101
+ schema. Reuses `Dsl::ValidationHelpers` verbatim.
102
+
103
+ **Alternatives rejected**: `accepts` / `param` — a third word for a concept the library
104
+ already names twice.
105
+
106
+ ### D2 — Inline steps declare a contract inside an `inputs do ... end` block
107
+
108
+ ```ruby
109
+ step :charge do
110
+ inputs do
111
+ input :amount, :decimal, gt?: 0
112
+ input :currency, :string, included_in?: %w[USD EUR GBP]
113
+ end
114
+
115
+ argument :amount, input(:amount) # still the template reference
116
+ argument :currency, input(:currency)
117
+
118
+ run { |args, _| charge!(args) }
119
+ end
120
+ ```
121
+
122
+ **Rationale**: Finding 4. Inside the `inputs` block, `self` is a contract builder where
123
+ `input` unambiguously declares; outside it, `input(:x)` keeps meaning the template reference
124
+ it has always meant. Zero back-compat risk, no arity magic, and the declaration lines are
125
+ byte-identical to the ones in a step class — moving an inline step into a class is deleting
126
+ the wrapper (FR-007, SC-005).
127
+
128
+ **Alternatives rejected**:
129
+
130
+ - *Arity overload* — `input(:x)` returns a template, `input(:x, :string)` declares. Tempting
131
+ (no extra nesting) but `input :x` as a bare statement becomes silently meaningless, and the
132
+ same token in the same block would mean two different things depending on argument count.
133
+ This is exactly the "strange bugs and hard-to-debug validation errors" the spec exists to
134
+ remove.
135
+ - *Renaming the template helper* to `reactor_input(:x)` — breaks every existing reactor.
136
+
137
+ ### D3 — Enforcement lives in the step, via a prepended `run`
138
+
139
+ `Step.included(base)` prepends a wrapper onto `base.singleton_class`. The wrapper validates
140
+ the arguments against the contract and then calls `super`. A step with no contract skips
141
+ straight to `super` (Finding 6).
142
+
143
+ **Rationale**: one enforcement point covers all three call sites — the executor
144
+ (`step_executor.rb:353`), the async worker (`step_worker.rb:118`, Finding 2), and direct
145
+ invocation (FR-022) — instead of three. `TestSubject`'s mock wrapper
146
+ (`rspec/test_subject.rb:647`) calls `impl.run`, so mocked steps validate too.
147
+
148
+ On violation the wrapper **raises** `Error::InputValidationError` (Finding 3), not a `Failure`
149
+ — that is the protocol the executor, the rollback path, and `have_validation_error` already
150
+ speak.
151
+
152
+ **Step attribution**: the step class knows its own name but not the reactor's step name.
153
+ `safe_execute_step_sync`'s existing `rescue Error::InputValidationError` (`step_executor.rb:186`)
154
+ gains `e.step_name ||= step_config.name` before the re-raise — attribution stamped where the
155
+ name is known.
156
+
157
+ **Alternatives rejected**: building the step's validator into `StepConfig#args_validator` at
158
+ DSL time. Simpler-looking, but leaves the worker path unvalidated and direct invocation
159
+ unvalidated, and re-centralizes in the reactor what this feature is trying to move into the
160
+ step.
161
+
162
+ ### D4 — Inline contracts reuse `args_validator`; the worker path gets the missing call
163
+
164
+ An inline step has no class to prepend to, so its `inputs` block compiles to an
165
+ `args_validator` on `StepConfig`, enforced by the existing `validate_step_arguments`. To
166
+ close Finding 2, `StepWorker#execute_step_body` gains the same validation call, and its
167
+ `rescue StandardError` grows an `Error::InputValidationError` branch so the worker produces
168
+ the same failure shape rather than a generic `Failure(e)`.
169
+
170
+ Two mechanisms, one protocol: both raise `Error::InputValidationError`, both land in
171
+ `build_validation_failure`.
172
+
173
+ ### D5 — Reactor-side conflicts fail at the `step` macro
174
+
175
+ `StepBuilder#build` already has both `@impl` and `@arg_validations`. When `@impl` declares a
176
+ contract and the reactor supplied a type, predicates, `validate_args`, or an `inputs` block,
177
+ raise immediately — the error points at the offending line in the reactor class body
178
+ (FR-006). Same for an `argument` naming an input the contract does not declare (FR-018).
179
+
180
+ Message names the reactor, the step, the argument, and the owning step class.
181
+
182
+ ### D6 — Satisfiability is checked by `validate_definition!`, memoized, at first execution
183
+
184
+ FR-021 needs the reactor's full input list, which is not known while the class body is still
185
+ executing — `input` declarations may follow `step` declarations. Finding 7 rules out a
186
+ global boot-time sweep.
187
+
188
+ `Reactor.validate_definition!` walks every step with a contract and asserts each required
189
+ input is satisfied by an explicit `argument` or a same-named reactor input. It is memoized
190
+ and invoked from `Reactor.run`/`.call` before execution begins, and from `test_reactor`, and
191
+ is public so an application can call it in an initializer or CI check.
192
+
193
+ **Deviation from spec wording**: FR-008/FR-021 and US4 say "when the reactor class is
194
+ loaded". Conflict and unknown-argument checks (D5) genuinely are load-time. The
195
+ satisfiability check fires at first execution instead. The user-visible property the spec
196
+ cares about — the error names the reactor, step, and input, and does not depend on reaching
197
+ that step at run time — holds either way: a reactor whose wiring is incomplete fails before
198
+ step one runs, not on the unlucky run that first reaches the step.
199
+
200
+ **Alternatives rejected**: `TracePoint(:end)` to detect the end of a class body (clever,
201
+ unreadable, breaks on reopened classes); requiring `input` before `step` (silently breaks
202
+ valid existing reactors).
203
+
204
+ ### D7 — Name-based fallback resolves at `validate_definition!` time, reactor inputs only
205
+
206
+ An unwired declared input becomes `Template::Input.new(name)` appended to the step's
207
+ `arguments` — the same object an explicit `argument :x, input(:x)` produces, so nothing
208
+ downstream changes. Explicit wiring wins (never overwritten). Step results are never
209
+ consulted (FR-020), so resolution can't shift when an unrelated step is renamed.
210
+
211
+ ### D8 — Presence means "supplied", via one shared helper
212
+
213
+ Add `RubyReactor::Utils.fetch_indifferent(hash, key)` —
214
+ `hash.key?(key.to_sym) ? hash[key.to_sym] : hash[key.to_s]` — and use it at `context.rb:67`,
215
+ `context.rb:79`, and `template/result.rb:178`. Defaults (FR-013) apply when the key is absent
216
+ or the resolved value is `nil`; `false` is neither, so it survives.
217
+
218
+ **SemVer**: a fix, not a break — no documented behavior said `false` becomes `nil`.
219
+
220
+ ### D9 — Deprecation, not removal, for reactor-declared rules
221
+
222
+ `argument :x, src, :string, gt?: 0` on a step with no contract keeps working unchanged
223
+ (FR-010). It emits a one-time-per-site deprecation naming the `input` replacement once the
224
+ step's own contract is the documented path (FR-011). Removal is a later MAJOR.
225
+
226
+ ## Open risks
227
+
228
+ | Risk | Mitigation |
229
+ |---|---|
230
+ | Prepending to `singleton_class` surprises anyone who aliases or redefines `self.run` after `include` | Prepend happens at `include` time, so a later `def self.run` is still `super`'d correctly. Covered by a spec. |
231
+ | `inputs do` block inside `step` is a third nesting level | Only for inline steps; the constitution already names class steps the preferred style. |
232
+ | Two enforcement mechanisms (D3 prepend, D4 validator) could drift | Both raise the same error class through the same handler; a shared spec asserts identical outcomes for the class and inline forms (SC-005). |
233
+ | Falsey fix changes behavior for anyone relying on `false → nil` | Pre-existing defect; changelog note under Bug Fixes. |