ruby_reactor 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +26 -16
  5. data/.specify/templates/plan-template.md +4 -0
  6. data/.specify/templates/tasks-template.md +1 -1
  7. data/CHANGELOG.md +140 -0
  8. data/CLAUDE.md +1 -1
  9. data/README.md +125 -31
  10. data/lib/ruby_reactor/context.rb +2 -2
  11. data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
  12. data/lib/ruby_reactor/dsl/reactor.rb +36 -18
  13. data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
  14. data/lib/ruby_reactor/dsl/template_helpers.rb +2 -2
  15. data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
  16. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  17. data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
  18. data/lib/ruby_reactor/executor/result_handler.rb +9 -3
  19. data/lib/ruby_reactor/executor/retry_manager.rb +2 -1
  20. data/lib/ruby_reactor/executor/step_executor.rb +9 -2
  21. data/lib/ruby_reactor/executor.rb +3 -0
  22. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
  23. data/lib/ruby_reactor/reactor.rb +9 -12
  24. data/lib/ruby_reactor/rspec/matchers.rb +3 -6
  25. data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
  26. data/lib/ruby_reactor/step/compose_step.rb +56 -75
  27. data/lib/ruby_reactor/step/input_contract.rb +128 -0
  28. data/lib/ruby_reactor/step/map_step.rb +177 -218
  29. data/lib/ruby_reactor/step.rb +116 -21
  30. data/lib/ruby_reactor/step_signals.rb +6 -2
  31. data/lib/ruby_reactor/step_worker.rb +25 -10
  32. data/lib/ruby_reactor/template/result.rb +9 -2
  33. data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
  34. data/lib/ruby_reactor/version.rb +1 -1
  35. data/lib/ruby_reactor.rb +5 -2
  36. data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
  37. data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
  38. data/specs/002-step-input-contracts/data-model.md +115 -0
  39. data/specs/002-step-input-contracts/plan.md +165 -0
  40. data/specs/002-step-input-contracts/quickstart.md +170 -0
  41. data/specs/002-step-input-contracts/research.md +233 -0
  42. data/specs/002-step-input-contracts/spec.md +359 -0
  43. data/specs/002-step-input-contracts/tasks.md +367 -0
  44. data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
  45. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
  46. data/specs/004-inheritable-step-class/data-model.md +116 -0
  47. data/specs/004-inheritable-step-class/plan.md +174 -0
  48. data/specs/004-inheritable-step-class/quickstart.md +112 -0
  49. data/specs/004-inheritable-step-class/research.md +308 -0
  50. data/specs/004-inheritable-step-class/spec.md +316 -0
  51. data/specs/004-inheritable-step-class/tasks.md +258 -0
  52. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
  53. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
  54. data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
  55. data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
  56. data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
  57. data/specs/deferred-003-step-lock-declarations/research.md +196 -0
  58. data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
  59. data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
  60. data/specs/possible_feature.md +22 -0
  61. metadata +28 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: abde4c2dc313b2e5d4a74873f1fc337743e6847e8f47fcbd121fd1a72be1d557
4
- data.tar.gz: defd25588b78022c339ffd105d4e304d80e676498272c887cafecb60f100dc7e
3
+ metadata.gz: 6e671a274b5dc3854189ab1f3c40ff4d7e307a567ed960dbad0be1405f05ff58
4
+ data.tar.gz: 0a44e9cf8dbed99b72762381300c4c503500d3025f21288e473eef377a28dbec
5
5
  SHA512:
6
- metadata.gz: 595c701f476086c166d0e157c062e0bea0e6fb243b1c5466017bf488461b987b44c70b56e081770c586a1d83519311c58da9769ae83e302fb7d17d1cdc8f37fb
7
- data.tar.gz: 7dadad987df2ec6d9c1b44e93ed328f07d77660310726dceeb1ee8957cc9e63b73bd3c2338792280bfc780a95650bcac12b384af3d2aad83b3309aa4896f6d0d
6
+ metadata.gz: a39666bdba5d7c911589da0f30a12e29a9f5187c31a9a1bfb9ddc0c55aba26cac201db3e8083854f79858d6845ed98097589979873a0a4aa7cc8dfdaca635856
7
+ data.tar.gz: a5069c91a3942f1697a317baee2223b790b4262ca6d90f61a1b3aa0cd8b61ff9b199b7a05f58f791df11bd7b4336611907a6abd3cbce6ebfd007d9bc92949c4e
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.7.1"
2
+ ".": "0.8.0"
3
3
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "feature_directory": "specs/001-reactor-signal-semantics"
2
+ "feature_directory": "specs/004-inheritable-step-class"
3
3
  }
@@ -1,25 +1,27 @@
1
1
  <!--
2
2
  SYNC IMPACT REPORT
3
3
  ==================
4
- Version change: 1.1.0 → 1.2.0 (MINOR: Principle VI expanded with a 4th requirement —
5
- docker-compose.yml currency + docker-run acceptance tests)
4
+ Version change: 1.2.1 → 1.3.0 (MINOR: Development Workflow now REQUIRES every generated
5
+ plan.md and tasks.md to carry an explicit documentation-update task/gate, with the
6
+ requirement propagated into both templates — a new enforcement mechanism, not just
7
+ wording)
6
8
 
7
- Modified principles:
8
- - Principle VI: Demo-App Proof of Feature — added requirement 4, "Docker acceptance
9
- run" (docker-compose.yml MUST track demo_app's services; demo: rake tasks are the
10
- project's acceptance suite, runnable via `docker compose run`)
9
+ Modified principles: none (Development Workflow section, not a Core Principle)
11
10
 
12
- Added sections: none (existing Principle VI section extended)
11
+ Added sections: none
13
12
 
14
13
  Removed sections: none
15
14
 
16
15
  Templates checked:
17
- - .specify/templates/plan-template.md ✅ Constitution Check gate is generic ("Gates determined
18
- based on constitution file") — no edit required
19
- - .specify/templates/spec-template.md ✅ No principle-specific content — no edit required
20
- - .specify/templates/tasks-template.md Already covers demo-app polish tasks no edit required
16
+ - .specify/templates/plan-template.md Added Constitution Check checklist item:
17
+ identify README.md/./documentation impact
18
+ - .specify/templates/spec-template.md ✅ No principle-specific content — no edit required
19
+ - .specify/templates/tasks-template.md Polish-phase doc task now explicit and marked
20
+ REQUIRED, citing README.md + ./documentation
21
21
  - .specify/templates/checklist-template.md ✅ Generic — no edit required
22
- - .specify/extensions.yml ✅ No before/after_constitution hooks registered
22
+ - .specify/extensions.yml ✅ No before/after_constitution hooks registered
23
+ - README.md ✅ No principle-specific claims to update
24
+ - documentation/*.md ✅ No principle-specific claims to update
23
25
 
24
26
  Deferred TODOs: none
25
27
  -->
@@ -164,9 +166,17 @@ gaps in the matcher library surface as work instead of as workarounds.
164
166
  - Feature branches target `main`. PRs MUST pass CI (RSpec + RuboCop) before merge.
165
167
  - Releases are managed by release-please. Version bump lives in
166
168
  `lib/ruby_reactor/version.rb`. Do not manually edit the version in gemspec.
167
- - New features MUST update `README.md` documentation and add entries to
168
- `CHANGELOG.md` under the correct semantic heading (`Features`, `Bug Fixes`,
169
- `documentation`).
169
+ - Every task MUST update documentation as part of its own change, not as a
170
+ follow-up: `README.md` and any affected file under `./documentation` MUST be
171
+ kept current with the behavior the task introduces, changes, or removes. A
172
+ task that alters observable behavior without a matching documentation update
173
+ is incomplete. New features additionally MUST add entries to `CHANGELOG.md`
174
+ under the correct semantic heading (`Features`, `Bug Fixes`, `documentation`).
175
+ - Every `plan.md` produced by `/speckit-plan` and every `tasks.md` produced by
176
+ `/speckit-tasks` MUST include at least one explicit task or checklist item to
177
+ update `README.md` and the relevant file(s) under `./documentation` for the
178
+ feature's behavior. A plan or task list that omits a documentation-update
179
+ task is incomplete and MUST be rejected in review.
170
180
  - Class-based step definitions are the preferred authoring style (not inline
171
181
  lambdas). Documentation and examples MUST reflect this.
172
182
  - The `demo_app/` directory serves as a living integration example. Changes to
@@ -198,4 +208,4 @@ justified in the `Complexity Tracking` table of the plan.
198
208
  Compliance review: at each MINOR or MAJOR gem release, confirm this constitution
199
209
  still accurately reflects the codebase and update as needed.
200
210
 
201
- **Version**: 1.2.0 | **Ratified**: 2025-10-02 | **Last Amended**: 2026-09-09
211
+ **Version**: 1.3.0 | **Ratified**: 2025-10-02 | **Last Amended**: 2026-09-11
@@ -42,6 +42,10 @@
42
42
 
43
43
  [Gates determined based on constitution file]
44
44
 
45
+ - [ ] Documentation impact identified: which `README.md` sections and which
46
+ file(s) under `./documentation` this feature will require updating
47
+ (Constitution Development Workflow — carried into tasks.md as a required task)
48
+
45
49
  ## Project Structure
46
50
 
47
51
  ### Documentation (this feature)
@@ -151,7 +151,7 @@ Examples of foundational tasks (adjust based on your project):
151
151
 
152
152
  **Purpose**: Improvements that affect multiple user stories
153
153
 
154
- - [ ] TXXX [P] Documentation updates in docs/
154
+ - [ ] TXXX [P] Update `README.md` and affected file(s) under `./documentation` for this feature (REQUIRED — Constitution Development Workflow)
155
155
  - [ ] TXXX Code cleanup and refactoring
156
156
  - [ ] TXXX Performance optimization across all stories
157
157
  - [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
data/CHANGELOG.md CHANGED
@@ -1,5 +1,145 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### ⚠ BREAKING CHANGES
6
+
7
+ * **`RubyReactor::Step` is now a base class, not a mixin.** `include RubyReactor::Step` on a
8
+ plain class with `def self.run(arguments, context)` is gone — no compatibility shim, no dual
9
+ authoring style. A step subclasses `RubyReactor::Step` and writes `run` (and optionally
10
+ `compensate`/`undo`) as **instance** methods reading the validated arguments and the context
11
+ through `inputs`/`context` accessors instead of parameters. Every instance is built fresh for
12
+ its one action (`run`, `undo`, or `compensate`) from the stored arguments/result/reason alone —
13
+ nothing an author sets in `run` is visible in a later `undo`/`compensate`, which is exactly what
14
+ makes rollback behave identically whether it lands in the same process as `run` or, as with an
15
+ `async_step`, in a separate later one. `inputs` holds the same values in all three actions: the
16
+ arguments with the contract's defaults applied. Only `run` enforces the contract; `undo` and
17
+ `compensate` never do, so rollback cannot fail on the inputs that caused the failure.
18
+
19
+ ```ruby
20
+ # Before
21
+ class ChargeStep
22
+ include RubyReactor::Step
23
+
24
+ input :amount, :integer, gteq?: 1
25
+
26
+ def self.run(arguments, context)
27
+ Success(charge!(arguments[:amount]))
28
+ end
29
+
30
+ def self.undo(result, arguments, context)
31
+ refund!(result[:charge_id])
32
+ Success()
33
+ end
34
+ end
35
+
36
+ # After
37
+ class ChargeStep < RubyReactor::Step
38
+ input :amount, :integer, gteq?: 1
39
+
40
+ def run
41
+ Success(charge!(inputs[:amount]))
42
+ end
43
+
44
+ def undo
45
+ refund!(result[:charge_id])
46
+ Success()
47
+ end
48
+ end
49
+ ```
50
+
51
+ **Migration:** for every class step, replace `include RubyReactor::Step` with
52
+ `< RubyReactor::Step`, turn `def self.run(args, ctx)` into `def run` reading `inputs`/`context`,
53
+ and likewise for `def self.undo(result, args, ctx)` / `def self.compensate(reason, args, ctx)` →
54
+ `def undo` / `def compensate` reading `result`/`reason`/`inputs`/`context`. The class-level
55
+ `MyStep.run(arguments, context)` / `.call` / `.undo(result, arguments, context)` /
56
+ `.compensate(reason, arguments, context)` entry points every caller (executor, worker, a direct
57
+ unit-test call) already used are unchanged. Inline `step { run { |args, ctx| ... } }` blocks are
58
+ untouched by this change.
59
+
60
+ * **A class step's signal helpers (`success!`/`fail!`/`skip!`/`halt!`) now translate correctly on
61
+ every execution path, including the `async_step`/`background` worker.** Previously the worker had
62
+ no `catch` of its own, so a signal thrown from a class step running there escaped as an
63
+ `UncaughtThrowError` instead of the intended `Success`/`Failure`/`Skipped`/`Halt` — the base
64
+ class's class-level `run`/`undo`/`compensate` now own that translation, so every caller gets it
65
+ for free with no worker change. Known remaining gap, unchanged by this release: an **inline**
66
+ `run`/`compensate`/`undo` block's signals are still uncaught on the worker path.
67
+
68
+ * **A step's own input-validation failure is now guaranteed non-retryable on every path.**
69
+ `result.retryable?` is `false` whether the violation happened synchronously, inside an
70
+ `async_step`/`background` worker, or inside a `compose`d child's own step — previously only the
71
+ async worker path got this right; the synchronous path and a validation failure surfacing
72
+ through `compose` both defaulted to `retryable? == true`.
73
+
74
+ * **`Failure(...)` takes the same arguments everywhere.** Inside a class step and inside an inline
75
+ `run`/`compensate`/`undo` block, `Failure` now forwards every argument to `RubyReactor.Failure`,
76
+ so options such as `Failure("declined", retryable: false)` work instead of raising
77
+ `ArgumentError`. A bare `Failure()` with no error is no longer accepted, matching
78
+ `RubyReactor.Failure`, and a hash error needs braces, `Failure({ code: 1 })`, because a braceless
79
+ `Failure(code: 1)` is now read as options.
80
+
81
+ ### Features
82
+
83
+ * **Step input contracts.** A step class declares its own inputs with `input :name, :type, **predicates`
84
+ (plus `optional:`, `default:`, `redact:`, the `do |i| ... end` macro block and `validate:`) and
85
+ cross-field rules with `validate_inputs`. The contract is enforced before `run` on every path
86
+ (inline, retries, `async_step` and `background` workers, resume, `map`, and a direct
87
+ `Step.run(args, context)` call), and a violation fails with `validation_errors` and the step's
88
+ name after completed steps are rolled back. Subclasses inherit and extend the contract.
89
+ Introspection: `input_contract`, `declared_inputs`, `required_input_names`, `declares_inputs?`.
90
+ * **Inline step contracts.** `inputs do ... end` inside a `step` block takes the same `input` /
91
+ `validate_inputs` lines as a step class and is enforced the same way.
92
+ * **Wiring by name.** A declared input with no `argument` resolves from the reactor input of the
93
+ same name (never from a step result). A required input that is neither wired nor a reactor input
94
+ raises `Error::ValidationError` before any step runs. `Reactor.validate_definition!` runs that
95
+ check on demand, e.g. from an initializer or CI.
96
+ * For a step that owns a contract, a type or predicate on `argument`, `validate_args`, or an
97
+ `argument` for an undeclared input raises `Error::ValidationError` when the `step` line is
98
+ evaluated.
99
+ * `have_validation_error` now also matches validation failures raised at a step, not only
100
+ reactor-input failures.
101
+ * `Failure#to_h` includes `retryable`, so a non-retryable failure stays non-retryable after it
102
+ crosses a worker boundary.
103
+ * A step that returns another unit's validation failure (e.g. an `async_step` reader propagating
104
+ the worker's `Failure`) keeps its `validation_errors` on the reactor's final failure.
105
+
106
+ ### Deprecations
107
+
108
+ * Rules on `argument` (`argument :x, src, :type, **predicates`) and `validate_args` keep working
109
+ for steps without a contract, and print one deprecation notice per declaration site. Move them
110
+ to `input` / `validate_inputs` on the step class, or into an `inputs do ... end` block for an
111
+ inline step, and keep `argument :x, src` for wiring. Removal is no earlier than the next major
112
+ version. See "Step Input Contracts" in the README for the migration.
113
+
114
+ ### Bug Fixes
115
+
116
+ * A supplied `false` reactor input or step result no longer resolves to `nil`.
117
+ `Context#get_input`, `Context#get_result` and `Template::Result#fetch` now check whether the key
118
+ exists instead of whether the value is truthy. Code that relied on `false` arriving as `nil`
119
+ will now see `false`.
120
+ * An input-validation `Failure` stays non-retryable across serialization. `Failure`'s
121
+ hash extractor and the reactor's stored `failure_reason` both dropped a `retryable: false`
122
+ (`||` swallowed the `false`, and the reactor never stored the flag at all), so a failure
123
+ rebuilt from JSON or reloaded with `Reactor.find` reported `retryable? == true`.
124
+ * A step-contract violation inside `compose` now reaches the parent with its `validation_errors`
125
+ and retryability intact, instead of being rebuilt from the child's error message alone.
126
+ * A reactor reopened after its first run is re-checked: `validate_definition!` no longer memoizes,
127
+ so a step declared later can no longer reach execution with a required input unwired.
128
+ * A class step that calls `halt!` under `async_step` is recorded as a halt rather than an
129
+ ordinary `nil` success, and `result(:step)` hands the reader the `Halt` — the same way it
130
+ already hands over a `Failure`.
131
+
132
+ ## [0.8.0](https://github.com/arturictus/ruby_reactor/compare/v0.7.1...v0.8.0) (2026-09-21)
133
+
134
+
135
+ ### ⚠ BREAKING CHANGES
136
+
137
+ * Step Instance and input validations per step ([#51](https://github.com/arturictus/ruby_reactor/issues/51))
138
+
139
+ ### Features
140
+
141
+ * Step Instance and input validations per step ([#51](https://github.com/arturictus/ruby_reactor/issues/51)) ([4a20583](https://github.com/arturictus/ruby_reactor/commit/4a2058399d73e9b3bf767659dd5ba080537dc291))
142
+
3
143
  ## [0.7.1](https://github.com/arturictus/ruby_reactor/compare/v0.7.0...v0.7.1) (2026-09-14)
4
144
 
5
145
 
data/CLAUDE.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <!-- SPECKIT START -->
2
2
  For additional context about technologies to be used, project structure,
3
3
  shell commands, and other important information, read the current plan:
4
- `specs/001-reactor-signal-semantics/plan.md`
4
+ `specs/004-inheritable-step-class/plan.md`
5
5
  <!-- SPECKIT END -->
data/README.md CHANGED
@@ -239,23 +239,24 @@ Whichever style you use, a step's `run` returns one of four signals — all expo
239
239
 
240
240
  One-line helpers end a step immediately from any call depth: `success!(value)`, `fail!(error, retry: true)`, `halt!(reason:)`, `skip!(value)` — equivalent to `return`ing the matching signal, usable in `run`, `compensate`, and `undo` bodies.
241
241
 
242
- **Class steps** are plain Ruby classes that include `RubyReactor::Step` and implement `run`, and optionally `compensate` and `undo`:
242
+ **Class steps** subclass `RubyReactor::Step` and implement `run`, and optionally `compensate` and `undo`, as instance methods reading `inputs` and `context`:
243
243
 
244
244
  ```ruby
245
- class ReserveInventoryStep
246
- include RubyReactor::Step
245
+ class ReserveInventoryStep < RubyReactor::Step
246
+ # The step's input contract: enforced before `run` on every execution path.
247
+ input :order, :hash
247
248
 
248
- def self.run(arguments, context)
249
- reservation_id = InventoryService.reserve(arguments[:order][:items])
249
+ def run
250
+ reservation_id = InventoryService.reserve(inputs[:order][:items])
250
251
  Success(reservation_id: reservation_id)
251
252
  end
252
253
 
253
- def self.compensate(error, arguments, context)
254
- InventoryService.release_partial(arguments[:order][:items])
254
+ def compensate
255
+ InventoryService.release_partial(inputs[:order][:items])
255
256
  Success()
256
257
  end
257
258
 
258
- def self.undo(result, arguments, context)
259
+ def undo
259
260
  InventoryService.release(result[:reservation_id])
260
261
  Success()
261
262
  end
@@ -322,29 +323,25 @@ RubyReactor allows you to define complex workflows as "reactors" with steps that
322
323
  ```ruby
323
324
  require 'ruby_reactor'
324
325
 
325
- class ValidateEmailStep
326
- include RubyReactor::Step
327
-
328
- def self.run(arguments, _context)
329
- email = arguments[:email]
326
+ class ValidateEmailStep < RubyReactor::Step
327
+ def run
328
+ email = inputs[:email]
330
329
  email&.include?('@') ? Success(email.strip) : Failure("Email must contain @")
331
330
  end
332
331
  end
333
332
 
334
- class CreateUserStep
335
- include RubyReactor::Step
336
-
337
- def self.run(arguments, _context)
333
+ class CreateUserStep < RubyReactor::Step
334
+ def run
338
335
  Success(
339
336
  id: rand(10000),
340
- email: arguments[:email],
341
- password_hash: arguments[:password_hash],
337
+ email: inputs[:email],
338
+ password_hash: inputs[:password_hash],
342
339
  created_at: Time.now
343
340
  )
344
341
  end
345
342
 
346
- def self.compensate(_error, arguments, _context)
347
- Notify.to(arguments[:email])
343
+ def compensate
344
+ Notify.to(inputs[:email])
348
345
  Success()
349
346
  end
350
347
  end
@@ -1018,26 +1015,123 @@ result.error # => RubyReactor::Error::InputValidationError
1018
1015
  result.error.field_errors[:name] # => "size cannot be less than 2"
1019
1016
  ```
1020
1017
 
1021
- ### Step Argument & Output Validation
1018
+ ### Step Input Contracts
1022
1019
 
1023
- Arguments can be validated inline using the same forms as `input`. Inline rules
1024
- compose with a `validate_args` block (used for cross-field rules):
1020
+ A step declares the inputs it accepts; the reactor only says where each value
1021
+ comes from. Rules live with the unit of work, so a step reused by three
1022
+ reactors is validated the same way in all three.
1025
1023
 
1026
1024
  ```ruby
1027
- step :charge do
1028
- argument :amount, input(:amount), :decimal, gt?: 0
1029
- argument :currency, input(:currency), :string, included_in?: %w[USD EUR GBP]
1030
- argument :user, input(:user), User # type? instance check
1025
+ class ChargeStep < RubyReactor::Step
1026
+ input :amount, :integer, gteq?: 1
1027
+ input :currency, :string, included_in?: %w[USD EUR GBP]
1028
+ input :user, User # type? instance check
1029
+ input :note, :string, optional: true, default: "", max_size?: 100
1030
+ input :token, :string, redact: true # "[REDACTED]" in failures and traces
1031
+
1032
+ # Cross-field rules over the whole argument hash, applied last
1033
+ validate_inputs do
1034
+ required(:amount).filled(:integer, lt?: 10_000)
1035
+ end
1036
+
1037
+ def run
1038
+ Success(charge!(inputs))
1039
+ end
1040
+ end
1041
+ ```
1042
+
1043
+ `input` takes the same forms as a reactor `input` (inline type and predicates,
1044
+ `do |i| ... end` macro block, `validate: Schema`). The contract is enforced before
1045
+ `run` on every path: inline execution, each retry attempt, `async_step` and
1046
+ `background` workers, resume, every `map` element, and a direct
1047
+ `ChargeStep.run(args, context)` call. A violation raises
1048
+ `RubyReactor::Error::InputValidationError`; inside a reactor that becomes a
1049
+ `Failure` with `validation_errors` and the step's name, after completed steps are
1050
+ rolled back, and `have_validation_error(:amount)` matches it.
1051
+
1052
+ **Wiring.** `argument` only maps values:
1053
+
1054
+ ```ruby
1055
+ class ChargeReactor < RubyReactor::Reactor
1056
+ input :amount
1057
+ input :currency
1058
+ input :user
1059
+
1060
+ step :charge, ChargeStep do
1061
+ argument :user, result(:load_user) # explicit wiring always wins
1062
+ end # :amount and :currency resolve by name
1063
+ end
1064
+ ```
1065
+
1066
+ A declared input with no `argument` resolves from the reactor input of the same
1067
+ name. Step results are never used for this. A required input that is neither
1068
+ wired nor a reactor input raises `RubyReactor::Error::ValidationError` before any
1069
+ step runs, naming the reactor, the step, the input, and both fixes.
1070
+ `ChargeReactor.validate_definition!` runs the same check without running the
1071
+ reactor, e.g. in an initializer or a CI task.
1072
+
1073
+ For a step that owns a contract, these raise `RubyReactor::Error::ValidationError`
1074
+ when the `step` line is evaluated:
1031
1075
 
1032
- # Optional cross-field block (composes with the inline rules above)
1033
- validate_args do
1034
- required(:amount).filled(:decimal, lt?: 10_000)
1076
+ - a type or predicate on `argument` (`argument :amount, input(:amount), :integer`)
1077
+ - `validate_args`
1078
+ - an `argument` naming an input the step does not declare
1079
+
1080
+ There are no per-reactor overrides. If two reactors need different bounds for the
1081
+ same value, write two steps (or a parameterized step), or relax the contract.
1082
+
1083
+ **Inline steps** declare the same lines inside `inputs do ... end`. Inside the
1084
+ block `input` declares; outside it, `input(:x)` is still the template reference:
1085
+
1086
+ ```ruby
1087
+ step :charge do
1088
+ inputs do
1089
+ input :amount, :integer, gteq?: 1
1090
+ input :currency, :string, included_in?: %w[USD EUR GBP]
1035
1091
  end
1036
1092
 
1093
+ argument :amount, input(:amount)
1094
+ argument :currency, input(:currency)
1095
+
1037
1096
  run { |args, _| charge!(args) }
1038
1097
  end
1039
1098
  ```
1040
1099
 
1100
+ Moving an inline step into a class is deleting the `inputs do` wrapper.
1101
+
1102
+ **Presence.** A value is provided when its key exists, not when it is truthy:
1103
+
1104
+ | Supplied | Required input | Optional input with `default:` |
1105
+ |---|---|---|
1106
+ | `false`, `0`, `""`, `[]` | passes, value kept | value kept |
1107
+ | `nil` | fails ("must be filled") | default applied |
1108
+ | key absent | fails ("is missing") | default applied |
1109
+
1110
+ **Migrating from rules on `argument`.** Rules on `argument` and `validate_args`
1111
+ still work for steps with no contract, and print a one-time deprecation notice per
1112
+ declaration site. Removal is no earlier than the next major version.
1113
+
1114
+ ```ruby
1115
+ # Before
1116
+ step :charge, ChargeStep do
1117
+ argument :amount, input(:amount), :integer, gteq?: 1
1118
+ validate_args { required(:amount).filled(:integer, lt?: 10_000) }
1119
+ end
1120
+
1121
+ # After: the rules move into the step
1122
+ class ChargeStep < RubyReactor::Step
1123
+ input :amount, :integer, gteq?: 1
1124
+ validate_inputs { required(:amount).filled(:integer, lt?: 10_000) }
1125
+ end
1126
+
1127
+ step :charge, ChargeStep # or keep `argument :amount, input(:amount)` for wiring
1128
+ ```
1129
+
1130
+ For an inline step, put the same `input` / `validate_inputs` lines inside
1131
+ `inputs do ... end`.
1132
+
1133
+ ### Step Output Validation
1134
+
1041
1135
  Output validation is scalar-aware — pass a type/predicates for a single value,
1042
1136
  or a block for a hash output:
1043
1137
 
@@ -64,7 +64,7 @@ module RubyReactor
64
64
  end
65
65
 
66
66
  def get_input(name, path = nil)
67
- value = @inputs[name.to_sym] || @inputs[name.to_s]
67
+ value = Utils::FetchIndifferent.call(@inputs, name)
68
68
  return nil if value.nil?
69
69
 
70
70
  if path
@@ -76,7 +76,7 @@ module RubyReactor
76
76
  alias input get_input
77
77
 
78
78
  def get_result(step_name, path = nil)
79
- value = @intermediate_results[step_name.to_sym] || @intermediate_results[step_name.to_s]
79
+ value = Utils::FetchIndifferent.call(@intermediate_results, step_name)
80
80
  return nil if value.nil?
81
81
 
82
82
  if path
@@ -36,6 +36,12 @@ module RubyReactor
36
36
  end
37
37
  end
38
38
 
39
+ def inputs(*)
40
+ raise RubyReactor::Error::ValidationError,
41
+ "interrupt :#{@name} does not take an `inputs` contract; validate the resume payload with " \
42
+ "`validate_payload`."
43
+ end
44
+
39
45
  # Deprecated alias for {#validate_payload}.
40
46
  def validate(schema = nil, &block)
41
47
  unless @warned_validate
@@ -82,25 +82,11 @@ module RubyReactor
82
82
  input_validations[name] = validator if validator
83
83
  end
84
84
 
85
- # Dispatch across the layered `input` forms:
86
- # Form 3 pre-built schema / contract (`validate:`)
87
- # Form 2 — block bound to the value macro (`do |i| ... end`)
88
- # legacy — single-key schema block (`do required(:name)... end`)
89
- # Form 1 / 1b — inline scalar or class type
90
- # Form 0 — declaration only (no validator)
85
+ # The legacy single-key schema block is deprecated on reactor inputs
86
+ # only; the form dispatch itself is shared with step input contracts.
91
87
  def build_input_validator_for(name, type, optional, validate, predicates, &block)
92
- if validate
93
- create_input_validator(validate)
94
- elsif block
95
- if block.arity.nonzero?
96
- build_macro_validator(name, optional, &block)
97
- else
98
- warn_deprecated_input_block
99
- create_input_validator(block)
100
- end
101
- elsif type || predicates.any?
102
- build_inline_validator(name, type, optional, predicates)
103
- end
88
+ warn_deprecated_input_block if !validate && block&.arity&.zero?
89
+ build_declaration_validator(name, type, optional, validate, predicates, &block)
104
90
  end
105
91
  private :build_input_validator_for
106
92
 
@@ -163,6 +149,38 @@ module RubyReactor
163
149
  step_config
164
150
  end
165
151
 
152
+ # Checks that every required input of every contract-owning step is
153
+ # satisfied, and wires the unwired ones from same-named reactor inputs
154
+ # (never from step results). Runs before the first execution because
155
+ # the reactor's full input list is only known once its body has run;
156
+ # public so an app can call it at boot or in CI.
157
+ def validate_definition!
158
+ steps.each do |step_name, step_config|
159
+ contract = step_config.input_contract if step_config.respond_to?(:input_contract)
160
+ next unless contract
161
+
162
+ contract.declarations.each_value do |declaration|
163
+ wire_by_name!(step_name, step_config, declaration)
164
+ end
165
+ end
166
+ end
167
+
168
+ def wire_by_name!(step_name, step_config, declaration)
169
+ input_name = declaration.name
170
+ return if step_config.arguments.key?(input_name)
171
+
172
+ if inputs.key?(input_name)
173
+ step_config.arguments[input_name] =
174
+ { source: RubyReactor::Template::Input.new(input_name), transform: nil, origin: :inferred }
175
+ elsif !declaration.optional
176
+ raise RubyReactor::Error::ValidationError,
177
+ "#{name || inspect} step :#{step_name} requires input :#{input_name}, which is neither wired " \
178
+ "nor a reactor input. Wire it (`argument :#{input_name}, input(:x)` / `result(:step)`) or " \
179
+ "declare `input :#{input_name}` on the reactor."
180
+ end
181
+ end
182
+ private :wire_by_name!
183
+
166
184
  def returns(step_name = nil)
167
185
  if step_name
168
186
  reject_async_return_step!(step_name)