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
data/CHANGELOG.md CHANGED
@@ -1,5 +1,152 @@
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
+
143
+ ## [0.7.1](https://github.com/arturictus/ruby_reactor/compare/v0.7.0...v0.7.1) (2026-09-14)
144
+
145
+
146
+ ### Features
147
+
148
+ * reactor signal semantics ([#52](https://github.com/arturictus/ruby_reactor/issues/52)) ([9147d06](https://github.com/arturictus/ruby_reactor/commit/9147d066da603c0afc1af6536b6afac7e165a034))
149
+
3
150
  ## [0.7.0](https://github.com/arturictus/ruby_reactor/compare/v0.6.0...v0.7.0) (2026-09-08)
4
151
 
5
152
 
@@ -120,6 +267,10 @@ was dispatched precisely so the parent would not depend on it. A later step that
120
267
  reads the result and returns `Failure` triggers compensation normally, so no
121
268
  failure is unrecoverable, just not automatic.
122
269
 
270
+ * Reactor signal semantics: `Skipped` is renamed to `Halt` (the existing clean-stop behaviour, unchanged otherwise), and `Skipped` is reused with new meaning — marking a single step skipped while the reactor continues, with its value flowing to dependants exactly like `Success`. One-line outcome helpers `success!`, `fail!`, `skip!`, and `halt!` end a step immediately from any call depth. `Failure` (and `fail!`) accept a `retry:` spelling alongside the existing `retryable:`. `compensate`/`undo` now default to `Skipped` instead of `Success`, so the execution trace distinguishes rollback logic that ran from rollback logic that was never written.
271
+
272
+ **Migration**: `Skipped(reason: "...")` (the old halt) is now `Halt(reason: "...")`; `result.skipped?` for a clean halt is now `result.halted?`; the `be_skipped` matcher for a clean halt is now `be_halted`. The old call shape raises `ArgumentError` naming `Halt` — there is no silent compatibility path. Run status `:skipped` is renamed `:halted`; contexts persisted by a pre-upgrade version with status `"skipped"` are still read back correctly as halted.
273
+
123
274
  ## [0.5.4](https://github.com/arturictus/ruby_reactor/compare/v0.5.3...v0.5.4) (2026-06-18)
124
275
 
125
276
 
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
- shell commands, and other important information, read the current plan
4
- at specs/001-background-async-steps/plan.md
3
+ shell commands, and other important information, read the current plan:
4
+ `specs/004-inheritable-step-class/plan.md`
5
5
  <!-- SPECKIT END -->
data/README.md CHANGED
@@ -230,29 +230,33 @@ RubyReactor supports two ways to define step logic:
230
230
  | **Class steps** (preferred) | Real business logic, compensation/undo, shared steps, testability |
231
231
  | **Inline blocks** | Quick prototypes, trivial one-liners, documentation examples |
232
232
 
233
- Whichever style you use, a step's `run` returns one of three signals — all exposed as bare helpers in both class steps and inline blocks:
233
+ Whichever style you use, a step's `run` returns one of four signals — all exposed as bare helpers in both class steps and inline blocks:
234
234
 
235
235
  - **`Success(value)`** — step succeeded; `value` flows to dependent steps.
236
236
  - **`Failure(error)`** — step failed; the reactor rolls back completed steps (compensate/undo).
237
- - **`Skipped(reason:)`** — clean halt: stop the reactor, keep partial progress, **no rollback**. See [Skipping a reactor cleanly](documentation/core_concepts.md#skipping-a-reactor-cleanly).
237
+ - **`Halt(reason:)`** — clean halt: stop the reactor, keep partial progress, **no rollback**. See [Halting a reactor cleanly](documentation/core_concepts.md#halting-a-reactor-cleanly).
238
+ - **`Skipped(value)`** — mark this one step skipped; the reactor continues and `value` flows to dependants exactly like `Success`. See [Skipping a single step](documentation/core_concepts.md#skipping-a-single-step).
238
239
 
239
- **Class steps** are plain Ruby classes that include `RubyReactor::Step` and implement `run`, and optionally `compensate` and `undo`:
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
+
242
+ **Class steps** subclass `RubyReactor::Step` and implement `run`, and optionally `compensate` and `undo`, as instance methods reading `inputs` and `context`:
240
243
 
241
244
  ```ruby
242
- class ReserveInventoryStep
243
- 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
244
248
 
245
- def self.run(arguments, context)
246
- reservation_id = InventoryService.reserve(arguments[:order][:items])
249
+ def run
250
+ reservation_id = InventoryService.reserve(inputs[:order][:items])
247
251
  Success(reservation_id: reservation_id)
248
252
  end
249
253
 
250
- def self.compensate(error, arguments, context)
251
- InventoryService.release_partial(arguments[:order][:items])
254
+ def compensate
255
+ InventoryService.release_partial(inputs[:order][:items])
252
256
  Success()
253
257
  end
254
258
 
255
- def self.undo(result, arguments, context)
259
+ def undo
256
260
  InventoryService.release(result[:reservation_id])
257
261
  Success()
258
262
  end
@@ -319,29 +323,25 @@ RubyReactor allows you to define complex workflows as "reactors" with steps that
319
323
  ```ruby
320
324
  require 'ruby_reactor'
321
325
 
322
- class ValidateEmailStep
323
- include RubyReactor::Step
324
-
325
- def self.run(arguments, _context)
326
- email = arguments[:email]
326
+ class ValidateEmailStep < RubyReactor::Step
327
+ def run
328
+ email = inputs[:email]
327
329
  email&.include?('@') ? Success(email.strip) : Failure("Email must contain @")
328
330
  end
329
331
  end
330
332
 
331
- class CreateUserStep
332
- include RubyReactor::Step
333
-
334
- def self.run(arguments, _context)
333
+ class CreateUserStep < RubyReactor::Step
334
+ def run
335
335
  Success(
336
336
  id: rand(10000),
337
- email: arguments[:email],
338
- password_hash: arguments[:password_hash],
337
+ email: inputs[:email],
338
+ password_hash: inputs[:password_hash],
339
339
  created_at: Time.now
340
340
  )
341
341
  end
342
342
 
343
- def self.compensate(_error, arguments, _context)
344
- Notify.to(arguments[:email])
343
+ def compensate
344
+ Notify.to(inputs[:email])
345
345
  Success()
346
346
  end
347
347
  end
@@ -726,7 +726,7 @@ class MonthlyBillingReactor < RubyReactor::Reactor
726
726
  input :org_id
727
727
 
728
728
  # Run at most once per UTC month per org. Subsequent calls in the same month
729
- # return RubyReactor::Skipped without executing any step. Pair with
729
+ # return RubyReactor::Halt without executing any step. Pair with
730
730
  # with_lock for strict at-most-one even under concurrent racers.
731
731
  with_period(every: :month) { |inputs| "monthly_billing:#{inputs[:org_id]}" }
732
732
 
@@ -801,26 +801,38 @@ On contention:
801
801
  - **Inline** (`Reactor.run`) raises `RubyReactor::Lock::AcquisitionError` / `RubyReactor::Semaphore::AcquisitionError` / `RubyReactor::RateLimit::ExceededError` / `RubyReactor::OrderedLock::WaitError`.
802
802
  - **Async** (Sidekiq or ActiveJob) snoozes the job via `perform_in(delay, ...)`. For rate limits the delay uses the error's `retry_after_seconds` hint (precise wakeup — the bucket roll time is known exactly); for locks, semaphores, and ordered-lock waits it's `lock_snooze_base_delay + jitter` (a short re-poll, since a held lock or a live blocker nonce typically clears in milliseconds). Snoozes do not count against the backend's retry budget. After `lock_snooze_max_attempts` snoozes the context is marked failed (ordered-lock waits bypass the cap — see the ordered-lock docs).
803
803
 
804
- On dedup hits (period gate already marked), the reactor returns a `RubyReactor::Skipped` result instead — no steps run, no exception:
804
+ On dedup hits (period gate already marked), the reactor returns a `RubyReactor::Halt` result instead — no steps run, no exception:
805
805
 
806
806
  ```ruby
807
807
  result = MonthlyBillingReactor.run(org_id: 42)
808
- result.success? # true (Skipped is a Success subclass)
809
- result.skipped? # true on dedup hit, false otherwise
808
+ result.success? # true (Halt is a Success subclass)
809
+ result.halted? # true on dedup hit, false otherwise
810
810
  ```
811
811
 
812
- A step's `run` block can also return `Skipped(reason: "...")` to halt the reactor cleanly — remaining steps don't execute, **and already-completed steps are NOT compensated**. Use it when the rest of the workflow is unnecessary and partial progress should be kept (`Failure` is for "stop and roll back"). `Skipped` is a bare helper just like `Success`/`Failure` (or use the fully-qualified `RubyReactor.Skipped(...)`).
812
+ A step's `run` block can also return `Halt(reason: "...")` to stop the reactor cleanly — remaining steps don't execute, **and already-completed steps are NOT compensated**. Use it when the rest of the workflow is unnecessary and partial progress should be kept (`Failure` is for "stop and roll back"). `Halt` is a bare helper just like `Success`/`Failure` (or use the fully-qualified `RubyReactor.Halt(...)`).
813
813
 
814
814
  ```ruby
815
815
  step :ensure_active do
816
816
  argument :user, result(:fetch_user)
817
817
  run do |args, _ctx|
818
- next Skipped(reason: "user_opted_out") if args[:user].opted_out?
818
+ next Halt(reason: "user_opted_out") if args[:user].opted_out?
819
819
  Success(args[:user])
820
820
  end
821
821
  end
822
822
  ```
823
823
 
824
+ To skip a *single* step while the reactor continues — the step did nothing, but the rest of the workflow should still run — return `Skipped(value)` instead. The value flows to dependants exactly like a `Success` value, and the step is not enrolled for rollback:
825
+
826
+ ```ruby
827
+ step :maybe_sync do
828
+ argument :user, result(:fetch_user)
829
+ run do |args, _ctx|
830
+ next Skipped(args[:user]) if args[:user].already_synced?
831
+ Success(sync!(args[:user]))
832
+ end
833
+ end
834
+ ```
835
+
824
836
  See [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md) for re-entrancy, auto-extend, multi-window quotas, bucket semantics, owner identity, snooze tuning, ordered-lock assignment + poison-pill semantics, and operational notes.
825
837
 
826
838
  ### Map & Parallel Execution
@@ -1003,26 +1015,123 @@ result.error # => RubyReactor::Error::InputValidationError
1003
1015
  result.error.field_errors[:name] # => "size cannot be less than 2"
1004
1016
  ```
1005
1017
 
1006
- ### Step Argument & Output Validation
1018
+ ### Step Input Contracts
1007
1019
 
1008
- Arguments can be validated inline using the same forms as `input`. Inline rules
1009
- 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.
1010
1023
 
1011
1024
  ```ruby
1012
- step :charge do
1013
- argument :amount, input(:amount), :decimal, gt?: 0
1014
- argument :currency, input(:currency), :string, included_in?: %w[USD EUR GBP]
1015
- 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
1016
1036
 
1017
- # Optional cross-field block (composes with the inline rules above)
1018
- validate_args do
1019
- required(:amount).filled(:decimal, lt?: 10_000)
1037
+ def run
1038
+ Success(charge!(inputs))
1020
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:
1075
+
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]
1091
+ end
1092
+
1093
+ argument :amount, input(:amount)
1094
+ argument :currency, input(:currency)
1021
1095
 
1022
1096
  run { |args, _| charge!(args) }
1023
1097
  end
1024
1098
  ```
1025
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
+
1026
1135
  Output validation is scalar-aware — pass a type/predicates for a single value,
1027
1136
  or a block for a hash output:
1028
1137
 
@@ -1337,7 +1446,7 @@ Comprehensive guide to testing reactors with RubyReactor's testing utilities. Le
1337
1446
 
1338
1447
  ### [Locks, Semaphores, Rate Limits, Periods & Ordered Locks](documentation/locks_and_semaphores.md)
1339
1448
 
1340
- Coordinate access to shared resources across processes with Redis-backed primitives: exclusive locks (`with_lock`), concurrency-limiting semaphores (`with_semaphore`), fixed-window rate limits with multi-window quotas (`with_rate_limit`), calendar-bucketed dedup (`with_period`, returning `Skipped` results), and strict sequential ordering via a monotonically increasing nonce assigned at enqueue (`with_ordered_lock`). Covers re-entrancy across composed reactors, TTL auto-extend, inline-vs-async contention behavior, smart `retry_after` snoozes for rate limits, snooze tuning, the token-based semaphore safety model, once-per-day/month/year scheduling patterns, ordered-lock counter reset on drain, poison-pill timeouts, and deadlock-safe composition rules.
1449
+ Coordinate access to shared resources across processes with Redis-backed primitives: exclusive locks (`with_lock`), concurrency-limiting semaphores (`with_semaphore`), fixed-window rate limits with multi-window quotas (`with_rate_limit`), calendar-bucketed dedup (`with_period`, returning `Halt` results), and strict sequential ordering via a monotonically increasing nonce assigned at enqueue (`with_ordered_lock`). Covers re-entrancy across composed reactors, TTL auto-extend, inline-vs-async contention behavior, smart `retry_after` snoozes for rate limits, snooze tuning, the token-based semaphore safety model, once-per-day/month/year scheduling patterns, ordered-lock counter reset on drain, poison-pill timeouts, and deadlock-safe composition rules.
1341
1450
 
1342
1451
  ### [Middlewares & OpenTelemetry](documentation/middlewares.md)
1343
1452
 
@@ -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
@@ -103,6 +103,13 @@ module RubyReactor
103
103
  @current_step = old_step
104
104
  end
105
105
 
106
+ # Appends an execution-trace event stamped with whether this process is the
107
+ # worker (`inline_async_execution`). Traces written before this field
108
+ # existed stay unlabeled on deserialize.
109
+ def append_execution_trace(entry)
110
+ @execution_trace << entry.merge(background: !!@inline_async_execution)
111
+ end
112
+
106
113
  def to_h
107
114
  {
108
115
  inputs: @inputs,
@@ -36,6 +36,11 @@ module RubyReactor
36
36
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
37
37
  def serialize_value(value)
38
38
  case value
39
+ when RubyReactor::Halt
40
+ { "_type" => "Halt", "reason" => value.reason, "period_key" => value.period_key,
41
+ "step_name" => value.step_name }
42
+ when RubyReactor::Skipped
43
+ { "_type" => "Skipped", "value" => serialize_value(value.value), "reason" => value.reason }
39
44
  when RubyReactor::Success
40
45
  { "_type" => "Success", "value" => serialize_value(value.value) }
41
46
  when RubyReactor::Failure
@@ -112,6 +117,14 @@ module RubyReactor
112
117
  if value.key?("_type")
113
118
  # Special serialized types (Time, BigDecimal, etc.)
114
119
  case value["_type"]
120
+ when "Halt"
121
+ RubyReactor::Halt.new(
122
+ reason: value["reason"],
123
+ period_key: value["period_key"],
124
+ step_name: value["step_name"]
125
+ )
126
+ when "Skipped"
127
+ RubyReactor::Skipped.new(deserialize_value(value["value"]), reason: value["reason"])
115
128
  when "Success"
116
129
  RubyReactor::Success(deserialize_value(value["value"]))
117
130
  when "Failure"
@@ -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
@@ -53,7 +53,7 @@ module RubyReactor
53
53
 
54
54
  # Configure a calendar-aligned dedup window for this reactor. The
55
55
  # reactor will run at most once per bucket per key; subsequent calls
56
- # in the same bucket return `RubyReactor::Skipped` without executing
56
+ # in the same bucket return `RubyReactor::Halt` without executing
57
57
  # any steps.
58
58
  #
59
59
  # Note: `with_period` is *dedup*, not *concurrency*. Two concurrent
@@ -93,7 +93,7 @@ module RubyReactor
93
93
  # assign. Only fully-drained sequences GC themselves.
94
94
  # @param strict [Boolean] When true (default), if any nonce in the
95
95
  # sequence terminates with a `Failure`, all subsequent nonces are
96
- # short-circuited with `Skipped(reason: :ordered_lock_chain_failed)`
96
+ # short-circuited with `Halt(reason: :ordered_lock_chain_failed)`
97
97
  # instead of executing. This models "stop the line on the first
98
98
  # problem" pipelines (e.g. ledger transactions). When false, the
99
99
  # sequence keeps executing every nonce in order regardless of prior
@@ -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)