ruby_reactor 0.8.2 → 0.8.4

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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/speckit-review/SKILL.md +324 -0
  3. data/.release-please-manifest.json +1 -1
  4. data/.specify/extensions.yml +10 -0
  5. data/.specify/feature.json +1 -1
  6. data/.specify/workflows/speckit/workflow.yml +13 -1
  7. data/.specify/workflows/workflow-registry.json +2 -2
  8. data/CHANGELOG.md +94 -0
  9. data/CLAUDE.md +2 -2
  10. data/README.md +128 -93
  11. data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
  12. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
  13. data/lib/ruby_reactor/context.rb +26 -0
  14. data/lib/ruby_reactor/context_serializer.rb +4 -2
  15. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +3 -6
  16. data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
  17. data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
  18. data/lib/ruby_reactor/dsl/lockable.rb +76 -21
  19. data/lib/ruby_reactor/dsl/reactor.rb +8 -11
  20. data/lib/ruby_reactor/dsl/retryable.rb +45 -0
  21. data/lib/ruby_reactor/dsl/step_builder.rb +150 -13
  22. data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
  23. data/lib/ruby_reactor/error/execution_parked.rb +16 -0
  24. data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
  25. data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
  26. data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
  27. data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
  28. data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
  29. data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
  30. data/lib/ruby_reactor/executor/result_handler.rb +34 -13
  31. data/lib/ruby_reactor/executor/retry_manager.rb +20 -13
  32. data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
  33. data/lib/ruby_reactor/executor/step_executor.rb +115 -11
  34. data/lib/ruby_reactor/executor.rb +90 -20
  35. data/lib/ruby_reactor/map/element_executor.rb +24 -2
  36. data/lib/ruby_reactor/map/helpers.rb +35 -11
  37. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
  38. data/lib/ruby_reactor/open_telemetry.rb +61 -24
  39. data/lib/ruby_reactor/retry_context.rb +31 -2
  40. data/lib/ruby_reactor/rspec/helpers.rb +15 -0
  41. data/lib/ruby_reactor/rspec/matchers.rb +92 -0
  42. data/lib/ruby_reactor/rspec/test_subject.rb +9 -5
  43. data/lib/ruby_reactor/step/async_reactor_step.rb +46 -26
  44. data/lib/ruby_reactor/step/compose_step.rb +22 -7
  45. data/lib/ruby_reactor/step/input_contract.rb +5 -0
  46. data/lib/ruby_reactor/step/inputs.rb +57 -0
  47. data/lib/ruby_reactor/step/map_step.rb +32 -22
  48. data/lib/ruby_reactor/step.rb +59 -11
  49. data/lib/ruby_reactor/step_sweeper.rb +29 -1
  50. data/lib/ruby_reactor/step_worker.rb +260 -37
  51. data/lib/ruby_reactor/version.rb +1 -1
  52. data/lib/ruby_reactor/web/api.rb +72 -7
  53. data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
  54. data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
  55. data/lib/ruby_reactor/web/public/index.html +1 -1
  56. data/lib/ruby_reactor/worker.rb +56 -30
  57. data/lib/ruby_reactor.rb +30 -6
  58. data/specs/{004-inheritable-step-class → 006-step-retry-declarations}/checklists/requirements.md +10 -9
  59. data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
  60. data/specs/006-step-retry-declarations/data-model.md +58 -0
  61. data/specs/006-step-retry-declarations/plan.md +187 -0
  62. data/specs/006-step-retry-declarations/quickstart.md +80 -0
  63. data/specs/006-step-retry-declarations/research.md +194 -0
  64. data/specs/006-step-retry-declarations/spec.md +453 -0
  65. data/specs/006-step-retry-declarations/tasks.md +382 -0
  66. data/specs/future_improvements.md +250 -0
  67. data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
  68. metadata +20 -28
  69. data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
  70. data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
  71. data/specs/002-step-input-contracts/data-model.md +0 -115
  72. data/specs/002-step-input-contracts/plan.md +0 -165
  73. data/specs/002-step-input-contracts/quickstart.md +0 -170
  74. data/specs/002-step-input-contracts/research.md +0 -233
  75. data/specs/002-step-input-contracts/spec.md +0 -359
  76. data/specs/002-step-input-contracts/tasks.md +0 -367
  77. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
  78. data/specs/004-inheritable-step-class/data-model.md +0 -116
  79. data/specs/004-inheritable-step-class/plan.md +0 -174
  80. data/specs/004-inheritable-step-class/quickstart.md +0 -112
  81. data/specs/004-inheritable-step-class/research.md +0 -308
  82. data/specs/004-inheritable-step-class/spec.md +0 -316
  83. data/specs/004-inheritable-step-class/tasks.md +0 -258
  84. data/specs/active_job.md +0 -259
  85. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
  86. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
  87. data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
  88. data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
  89. data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
  90. data/specs/deferred-003-step-lock-declarations/research.md +0 -196
  91. data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
  92. data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
  93. data/specs/possible_feature.md +0 -22
@@ -0,0 +1,77 @@
1
+ # Plan: `inputs.order_id` (implement specs/inputs_by_method.md)
2
+
3
+ ## Context
4
+
5
+ `Step#inputs` is a plain Hash, so a typo like `inputs[:order_id]` (declared `:order_guid`) returns `nil` and blows up later, far from the bug — worst in `undo`/`compensate`, which skip validation. The design doc (`specs/inputs_by_method.md`) decides: step code reads inputs through a frozen read-only `Step::Inputs` object (`inputs.order_id`); any unreadable name raises `Error::UndeclaredInputError` naming the step and its declared inputs. No `[]`, no shim — every call site migrates in the same change (`feat!`). This plan implements that doc as written; deviations/additions found while reading the code are marked **(new)**.
6
+
7
+ Branch: `inputs_by_method` off `main`. Test-first (Constitution III): new specs first, then lib, then migration.
8
+
9
+ **Step 0 (on approval):** copy this plan to `specs/inputs_by_method_plan.md` (next to the design doc) and stop there. Implementation starts only when asked.
10
+
11
+ ## 1. New code (lib)
12
+
13
+ **`lib/ruby_reactor/step/inputs.rb`** — `RubyReactor::Step::Inputs` (Zeitwerk picks it up).
14
+ - `initialize(values, contract: nil, owner:)`: `@values = values.to_h` (so passing an `Inputs` or nil is safe), readable `@names` = `contract.declarations.keys` when the contract has declarations, else `@values.keys.map(&:to_sym)` **(new: a contract with only `validate_inputs` and no `input` falls back to present keys instead of making everything unreadable)**; `@redacted = contract&.redacted_names || []`; `freeze`.
15
+ - `method_missing(name, *args)`: readable name and no args → `Utils::FetchIndifferent.call(@values, name)`; else raise `UndeclaredInputError`. `respond_to_missing?` → `@names.include?(name) || super`.
16
+ - `to_h` → supplied readable names only, symbol keys, via FetchIndifferent (key present as sym or string). `alias to_hash to_h`.
17
+ - `inspect` → `to_h` with redacted names replaced by `InputContract::REDACTED`.
18
+
19
+ **`lib/ruby_reactor/error/undeclared_input_error.rb`** — `< NoMethodError`, `def retryable? = false`. Message: `"#{owner} has no input :#{name}. Declared inputs: :a, :b."` (`none` when empty). Retry path already honours it: `Failure#retryable?` (`lib/ruby_reactor.rb:167`) and `StepFailureError#retryable?` ask the error.
20
+
21
+ **Reserved names** — `InputContract#input` (`lib/ruby_reactor/step/input_contract.rb:25`): raise `Error::ValidationError` when `Step::Inputs.public_method_defined?(name)`. Checked all 141 input names in spec/demo_app/docs: no clashes.
22
+
23
+ ## 2. Injection points (the only places the object is built)
24
+
25
+ | Where | Change |
26
+ |---|---|
27
+ | `Step#initialize` (`lib/ruby_reactor/step.rb:44`) | `@inputs = Inputs.new(inputs, contract: (self.class.input_contract if self.class.declares_inputs?), owner: self.class.name)`. Covers `.run`/`.undo`/`.compensate`. Update header comment example (`step.rb:8`). |
28
+ | `Step.enforce_contract!` / `with_defaults` (`step.rb:168-179`) | **(new)** `arguments.to_h` first, so a documented nested call `OtherStep.run(inputs, context)` from a step body, and TestSubject's `original` callable, still hand the contract a Hash. |
29
+ | `StepConfig` (`lib/ruby_reactor/dsl/step_builder.rb`) | Add `def wrap_inputs(arguments) = Step::Inputs.new(arguments, contract: input_contract, owner: "step :#{name}")`. |
30
+ | `StepConfig#call_body` (`step_builder.rb:367`) | `run_block.call(wrap_inputs(arguments), context)`. Duck-typed `impl.run` (line 372) unchanged. |
31
+ | `CompensationManager#compensate_step` / `#undo_step` (`lib/ruby_reactor/executor/compensation_manager.rb:126,171`) | Wrap `arguments` with `step_config.wrap_inputs` before the inline block call only. |
32
+ | `TestSubject#original_impl_for` duck-impl lambda (`lib/ruby_reactor/rspec/test_subject.rb:772`) | **(new)** `impl.run(args.to_h, ctx)`. Mock blocks replace `@run_block`, so they receive `Inputs` like the body they stand in for — the `Step` path is covered by the `to_h` above. |
33
+
34
+ ## 3. Returning inputs from a step
35
+
36
+ - `RubyReactor.Success` (`lib/ruby_reactor.rb:391`): `Success.new(value.is_a?(Step::Inputs) ? value.to_h : value)`.
37
+ - `ResultHandler#handle_unknown_result` (`lib/ruby_reactor/executor/result_handler.rb:192`): build `success_result` first, then `validate_step_output` on `success_result.value`, **and (new) `@context.set_result(..., success_result.value)`** — the doc's swap alone would still store the raw `Inputs` in context.
38
+
39
+ ## 4. Built-in steps
40
+
41
+ Per the doc, `MapStep`, `ComposeStep`, `AsyncReactorStep` declare their keys (`input :source`, `input :fan_out, optional: true`, …, untyped → no validator, `enforce!` is a no-op) and switch `inputs[:x]` → `inputs.x` (31 sites). `fail_fast: inputs.fail_fast.nil? || inputs.fail_fast` stays.
42
+ - **(new)** drop `inputs[:step_name] ||` in `map_step.rb:193`: `step_name` is never wired, and once declared, `validate_definition!` would wire it by name from a reactor input called `:step_name`.
43
+
44
+ ## 5. Migration (no shim)
45
+
46
+ Counts: `inputs[` — spec 131, docs 49, README 10, demo_app 44; `args[` — spec 378, docs 157, README 54, demo_app 414 (most in inline blocks).
47
+ - Step bodies only (class `run`/`undo`/`compensate`, inline `run`/`undo`/`compensate` blocks, TestSubject mock blocks): `x[:k]` → `x.k`. Hand edits, not a global replace — lock/semaphore/rate-limit key procs, `validate_inputs`, `where`/`guard`, `transform:` procs stay Hashes.
48
+ - Other Hash use on inputs (`each`, `slice`, `eq(hash)` in specs) → `.to_h`. `**inputs`, `merge(inputs)`, `Success(inputs)` keep working.
49
+ - Docs/README/`demo_app/documentation`: rename inline block param `args` → `inputs`; `documentation/core_concepts.md` step-inputs section documents reader API, `UndeclaredInputError`, reserved names, `to_h`/`**`, `Success(inputs)` conversion (nested not converted).
50
+ - Order: lib → `spec/` → `demo_app/` → docs/README.
51
+
52
+ **Silent-pass trap:** a spec that expects a step to fail still passes if an unmigrated `[]` now fails it for a different reason. So grep is the driver and the suite is the net, not the other way round.
53
+
54
+ ## 6. Demo (Constitution VI)
55
+
56
+ Via `/speckit-demo-tests`: `demo_app/app/reactors/undeclared_input_demo_reactor.rb` (step reads a typo'd input), `demo:undeclared_input` in `demo_app/lib/tasks/demo_reactors.rake`, `demo_app/spec/reactors/undeclared_input_demo_reactor_spec.rb` asserting failure with `UndeclaredInputError` and one attempt despite `retries`.
57
+
58
+ ## 7. Tests (written first)
59
+
60
+ `spec/ruby_reactor/step/inputs_spec.rb`:
61
+ - reader returns value; declared-optional absent → `nil`; `false` stays `false`; string-keyed hash readable.
62
+ - undeclared name raises `UndeclaredInputError` with exact message; `respond_to?`; clash with private Kernel name (`select`) as a declared input reads fine.
63
+ - contract-less step reads present keys only; unwired inline step with `inputs do` reads only declared.
64
+ - `to_h` omits unsupplied optionals; `**inputs`; `inspect` redacts; frozen, no `[]`.
65
+ - `input :method` → `ValidationError` at class definition.
66
+ - class `undo`/`compensate` and inline `run`/`undo`/`compensate` blocks receive `Inputs`; typo in `compensate` surfaces as rollback failure naming the input.
67
+ - step with `retries max_attempts: 3` + typo → one attempt.
68
+ - `Success(inputs)` sync (`result(:step, :x)` works) and async round trip (Hash, not a string); bare `run { |inputs| inputs }` stored/validated as Hash.
69
+ - nested `OtherStep.run(inputs, context)` from a step body works; TestSubject `mock_step` with `original.call(args, ctx)` on a class step works.
70
+
71
+ ## Verification
72
+
73
+ 1. `bundle exec rspec` (real Redis) green; rerun flaky async specs alone before chasing them.
74
+ 2. `cd demo_app && bundle exec rspec` green; `bin/rails demo:undeclared_input` shows the error.
75
+ 3. `bundle exec rubocop` clean.
76
+ 4. Residual audit: `grep -rnE 'inputs\[|\bargs\[' lib spec demo_app documentation README.md` — every remaining hit is a Hash context (key procs, validators, `where`/`guard`, reactor DSL `inputs[name]` in `dsl/reactor.rb`).
77
+ 5. Commit as `feat!:` with a migration note (`inputs[:x]` → `inputs.x`, Hash methods → `inputs.to_h`).
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_reactor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ version: 0.8.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artur
@@ -98,6 +98,7 @@ files:
98
98
  - ".claude/skills/speckit-demo-tests/SKILL.md"
99
99
  - ".claude/skills/speckit-implement/SKILL.md"
100
100
  - ".claude/skills/speckit-plan/SKILL.md"
101
+ - ".claude/skills/speckit-review/SKILL.md"
101
102
  - ".claude/skills/speckit-specify/SKILL.md"
102
103
  - ".claude/skills/speckit-tasks/SKILL.md"
103
104
  - ".claude/skills/speckit-taskstoissues/SKILL.md"
@@ -164,6 +165,7 @@ files:
164
165
  - lib/ruby_reactor/dsl/lockable.rb
165
166
  - lib/ruby_reactor/dsl/map_builder.rb
166
167
  - lib/ruby_reactor/dsl/reactor.rb
168
+ - lib/ruby_reactor/dsl/retryable.rb
167
169
  - lib/ruby_reactor/dsl/step_builder.rb
168
170
  - lib/ruby_reactor/dsl/template_helpers.rb
169
171
  - lib/ruby_reactor/dsl/validation_helpers.rb
@@ -175,9 +177,13 @@ files:
175
177
  - lib/ruby_reactor/error/dependency_error.rb
176
178
  - lib/ruby_reactor/error/deprecated_dsl_error.rb
177
179
  - lib/ruby_reactor/error/deserialization_error.rb
180
+ - lib/ruby_reactor/error/execution_parked.rb
178
181
  - lib/ruby_reactor/error/input_validation_error.rb
182
+ - lib/ruby_reactor/error/reactor_contention_park.rb
179
183
  - lib/ruby_reactor/error/schema_version_error.rb
184
+ - lib/ruby_reactor/error/step_contention_park.rb
180
185
  - lib/ruby_reactor/error/step_failure_error.rb
186
+ - lib/ruby_reactor/error/undeclared_input_error.rb
181
187
  - lib/ruby_reactor/error/undo_error.rb
182
188
  - lib/ruby_reactor/error/validation_error.rb
183
189
  - lib/ruby_reactor/executor.rb
@@ -188,6 +194,7 @@ files:
188
194
  - lib/ruby_reactor/executor/ordered_lock_support.rb
189
195
  - lib/ruby_reactor/executor/result_handler.rb
190
196
  - lib/ruby_reactor/executor/retry_manager.rb
197
+ - lib/ruby_reactor/executor/step_coordination.rb
191
198
  - lib/ruby_reactor/executor/step_executor.rb
192
199
  - lib/ruby_reactor/interrupt_result.rb
193
200
  - lib/ruby_reactor/lock.rb
@@ -224,6 +231,7 @@ files:
224
231
  - lib/ruby_reactor/step/async_reactor_step.rb
225
232
  - lib/ruby_reactor/step/compose_step.rb
226
233
  - lib/ruby_reactor/step/input_contract.rb
234
+ - lib/ruby_reactor/step/inputs.rb
227
235
  - lib/ruby_reactor/step/map_step.rb
228
236
  - lib/ruby_reactor/step_signals.rb
229
237
  - lib/ruby_reactor/step_sweeper.rb
@@ -256,40 +264,24 @@ files:
256
264
  - lib/ruby_reactor/web/config.ru
257
265
  - lib/ruby_reactor/web/coordination_serializer.rb
258
266
  - lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css
259
- - lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js
267
+ - lib/ruby_reactor/web/public/assets/index-CeZU-ESu.js
260
268
  - lib/ruby_reactor/web/public/index.html
261
269
  - lib/ruby_reactor/web/public/vite.svg
262
270
  - lib/ruby_reactor/worker.rb
263
271
  - llms-full.txt
264
272
  - llms.txt
265
273
  - sig/ruby_reactor.rbs
266
- - specs/002-step-input-contracts/checklists/requirements.md
267
- - specs/002-step-input-contracts/contracts/dsl-surface.md
268
- - specs/002-step-input-contracts/data-model.md
269
- - specs/002-step-input-contracts/plan.md
270
- - specs/002-step-input-contracts/quickstart.md
271
- - specs/002-step-input-contracts/research.md
272
- - specs/002-step-input-contracts/spec.md
273
- - specs/002-step-input-contracts/tasks.md
274
- - specs/004-inheritable-step-class/checklists/requirements.md
275
- - specs/004-inheritable-step-class/contracts/step-lifecycle.md
276
- - specs/004-inheritable-step-class/data-model.md
277
- - specs/004-inheritable-step-class/plan.md
278
- - specs/004-inheritable-step-class/quickstart.md
279
- - specs/004-inheritable-step-class/research.md
280
- - specs/004-inheritable-step-class/spec.md
281
- - specs/004-inheritable-step-class/tasks.md
282
- - specs/active_job.md
283
- - specs/deferred-003-step-lock-declarations/checklists/requirements.md
284
- - specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md
285
- - specs/deferred-003-step-lock-declarations/data-model.md
286
- - specs/deferred-003-step-lock-declarations/plan.md
287
- - specs/deferred-003-step-lock-declarations/quickstart.md
288
- - specs/deferred-003-step-lock-declarations/research.md
289
- - specs/deferred-003-step-lock-declarations/spec.md
290
- - specs/deferred-003-step-lock-declarations/tasks.md
291
- - specs/possible_feature.md
274
+ - specs/006-step-retry-declarations/checklists/requirements.md
275
+ - specs/006-step-retry-declarations/contracts/dsl-surface.md
276
+ - specs/006-step-retry-declarations/data-model.md
277
+ - specs/006-step-retry-declarations/plan.md
278
+ - specs/006-step-retry-declarations/quickstart.md
279
+ - specs/006-step-retry-declarations/research.md
280
+ - specs/006-step-retry-declarations/spec.md
281
+ - specs/006-step-retry-declarations/tasks.md
282
+ - specs/future_improvements.md
292
283
  - specs/scoped_rspec_matchers.md
284
+ - specs/specs-inputs-by-method-md-piped-wigderson.md
293
285
  - teley/Dockerfile
294
286
  homepage: https://github.com/arturictus/ruby_reactor
295
287
  licenses: []
@@ -1,49 +0,0 @@
1
- # Specification Quality Checklist: Step Input Contracts
2
-
3
- **Purpose**: Validate specification completeness and quality before proceeding to planning
4
- **Created**: 2026-09-10
5
- **Feature**: [spec.md](../spec.md)
6
-
7
- ## Content Quality
8
-
9
- - [x] No implementation details (languages, frameworks, APIs)
10
- - [x] Focused on user value and business needs
11
- - [x] Written for non-technical stakeholders
12
- - [x] All mandatory sections completed
13
-
14
- ## Requirement Completeness
15
-
16
- - [x] No [NEEDS CLARIFICATION] markers remain
17
- - [x] Requirements are testable and unambiguous
18
- - [x] Success criteria are measurable
19
- - [x] Success criteria are technology-agnostic (no implementation details)
20
- - [x] All acceptance scenarios are defined
21
- - [x] Edge cases are identified
22
- - [x] Scope is clearly bounded
23
- - [x] Dependencies and assumptions identified
24
-
25
- ## Feature Readiness
26
-
27
- - [x] All functional requirements have clear acceptance criteria
28
- - [x] User scenarios cover primary flows
29
- - [x] Feature meets measurable outcomes defined in Success Criteria
30
- - [x] No implementation details leak into specification
31
-
32
- ## Notes
33
-
34
- - All items pass. 0 [NEEDS CLARIFICATION] markers remain.
35
- - Resolved with the user on 2026-09-10:
36
- - **Naming**: a unit of work declares `input`; the reactor keeps `argument` for wiring only
37
- (FR-012).
38
- - **Undeclared arguments**: reactor-load error for contract-owning steps (FR-018); steps
39
- with no contract and no wiring keep today's pass-all-inputs behavior (FR-019).
40
- - **Implicit wiring**: an unwired step input resolves from a same-named reactor input only,
41
- checked at reactor-load time; never from another step's result (FR-020, FR-021).
42
- - **Direct invocation**: the contract is enforced on every entry point, not only via a
43
- reactor (FR-022).
44
- - **Falsey values**: presence means "a value was supplied", never "the value is truthy";
45
- the pre-existing loss of `false` during argument resolution is corrected as part of this
46
- feature (FR-023, SC-011).
47
- - "Non-technical stakeholders" is read as *developers who are not this library's
48
- maintainers*: the spec names no Ruby constructs, gems, or file paths.
49
- - Ready for `/speckit-plan`.
@@ -1,193 +0,0 @@
1
- # Public DSL Contract: Step Input Contracts
2
-
3
- **Feature**: `specs/002-step-input-contracts/` | **Date**: 2026-09-10
4
-
5
- The gem's external interface is its DSL. This document is the contract that
6
- `spec/ruby_reactor/dsl/` specs assert against and that README must match.
7
-
8
- ## 1. `input` — declare a contract (step class)
9
-
10
- ```ruby
11
- input(name, type = nil, optional: false, default: nil, redact: false,
12
- validate: nil, **predicates, &block)
13
- ```
14
-
15
- Available on any class that `include RubyReactor::Step`. Signature is intentionally identical
16
- to the reactor's `input`, minus `transform:` (a step does not transform its own inputs — the
17
- reactor's `argument` does that).
18
-
19
- ```ruby
20
- class ValidatedUserStep
21
- include RubyReactor::Step
22
-
23
- input :name, :string, min_size?: 2
24
- input :email, :string
25
- input :age, :integer, gteq?: 18
26
- input :bio, :string, optional: true, default: "No bio provided", max_size?: 100
27
- input :token, :string, redact: true
28
-
29
- input :window do |i| # Form 2 — macro block
30
- i.filled(:integer, gteq?: 1, lteq?: 24)
31
- end
32
-
33
- input :payload, validate: PayloadSchema # Form 3 — pre-built schema
34
-
35
- def self.run(args, context)
36
- Success(profile_from(args))
37
- end
38
- end
39
- ```
40
-
41
- **Forms** (dispatch matches `Dsl::Reactor::ClassMethods#build_input_validator_for`):
42
-
43
- | Form | Written as | Compiles to |
44
- |---|---|---|
45
- | 0 | `input :x` | declaration only, no rule |
46
- | 1 | `input :x, :string, min_size?: 2` | `required(:x).filled(:string, min_size?: 2)` |
47
- | 1b | `input :x, User` | `required(:x).filled(type?: User)` |
48
- | 1-opt | `input :x, :string, optional: true` | `optional(:x).maybe(:string)` |
49
- | 2 | `input :x do \|i\| ... end` | block bound to the value macro |
50
- | 3 | `input :x, validate: Schema` | the supplied schema |
51
-
52
- ## 2. `validate_inputs` — cross-field rules (step class)
53
-
54
- ```ruby
55
- class ChargeStep
56
- include RubyReactor::Step
57
-
58
- input :amount, :decimal, gt?: 0
59
- input :currency, :string
60
-
61
- validate_inputs do
62
- required(:amount).filled(:decimal, lt?: 10_000)
63
- end
64
- end
65
- ```
66
-
67
- Composes with the per-input rules; applied last, wins on conflict — same precedence as the
68
- reactor's existing `validate_args`.
69
-
70
- ## 3. `inputs do ... end` — declare a contract (inline step)
71
-
72
- ```ruby
73
- step :charge do
74
- inputs do
75
- input :amount, :decimal, gt?: 0
76
- input :currency, :string, included_in?: %w[USD EUR GBP]
77
-
78
- validate_inputs do
79
- required(:amount).filled(:decimal, lt?: 10_000)
80
- end
81
- end
82
-
83
- argument :amount, input(:amount) # `input(:x)` here is still the template reference
84
- argument :currency, input(:currency)
85
-
86
- run { |args, _| charge!(args) }
87
- end
88
- ```
89
-
90
- The wrapper block exists because inside a `step` block, bare `input(:x)` already means
91
- "reference the reactor input" (`Dsl::TemplateHelpers#input`). Inside `inputs do`, `input`
92
- unambiguously declares. The declaration lines are byte-identical to a step class's, so moving
93
- an inline step into a class is deleting the wrapper.
94
-
95
- ## 4. `argument` — wiring only
96
-
97
- ```ruby
98
- argument(name, source, transform: nil)
99
- ```
100
-
101
- Unchanged for dependency resolution and value mapping.
102
-
103
- | Step owns a contract? | `argument :x, src` | `argument :x, src, :string, gt?: 0` | `validate_args do ... end` |
104
- |---|---|---|---|
105
- | Yes | ✅ | ❌ raises at the `step` macro | ❌ raises at the `step` macro |
106
- | No | ✅ | ✅ + deprecation notice | ✅ + deprecation notice |
107
-
108
- An `argument` naming an input a contract-owning step does not declare raises at the `step`
109
- macro.
110
-
111
- ## 5. Name-based resolution
112
-
113
- A declared input with no `argument` is satisfied by the reactor input of the same name.
114
-
115
- ```ruby
116
- class MyReactor < RubyReactor::Reactor
117
- input :amount
118
- input :currency
119
-
120
- step :charge, ChargeStep # both inputs resolved by name
121
- end
122
- ```
123
-
124
- Rules:
125
-
126
- - Reactor inputs only — never another step's result.
127
- - An explicit `argument` always wins and is never overwritten.
128
- - A required input satisfied by neither raises before execution begins, naming the step, the
129
- input, and both ways to satisfy it.
130
-
131
- ## 6. Introspection
132
-
133
- ```ruby
134
- ChargeStep.input_contract # => RubyReactor::Step::InputContract
135
- ChargeStep.declared_inputs # => { amount: InputDeclaration, ... }
136
- ChargeStep.required_input_names # => [:amount, :currency]
137
- ChargeStep.declares_inputs? # => true
138
- ```
139
-
140
- Read-only. Used by `validate_definition!`, by tooling, and by the dashboard.
141
-
142
- ## 7. Enforcement points
143
-
144
- | Entry point | Enforced | Mechanism |
145
- |---|---|---|
146
- | Reactor step execution | ✅ | prepended `run` (class) / `args_validator` (inline) |
147
- | Retry attempt | ✅ | same, re-validated per attempt |
148
- | `async_step` worker | ✅ | prepended `run` (class); explicit call in `StepWorker#execute_step_body` (inline) |
149
- | `background` hand-off worker | ✅ | ordinary step execution inside the worker |
150
- | Resume after interrupt | ✅ | ordinary step execution |
151
- | Each `map` iteration | ✅ | child reactor's own step execution |
152
- | `ChargeStep.run(args, ctx)` directly | ✅ | prepended `run` |
153
- | `compensate` / `undo` | ❌ by design | rollback receives already-validated arguments |
154
- | Step that a `where`/guard skips | ❌ by design | a step that never runs never validates |
155
-
156
- ## 8. Errors
157
-
158
- | Situation | Error | Carries |
159
- |---|---|---|
160
- | Contract violated | `RubyReactor::Error::InputValidationError` (raised) | `field_errors`, `step_name`, `step_arguments` |
161
- | Rules declared in reactor and step class | `RubyReactor::Error::ValidationError` at the `step` macro | reactor, step, argument, owning class |
162
- | `argument` for an undeclared input | `RubyReactor::Error::ValidationError` at the `step` macro | reactor, step, unknown argument |
163
- | Required input unwired and unmatched | `RubyReactor::Error::ValidationError` before execution | reactor, step, input, both remedies |
164
- | `default:` on a required input | `RubyReactor::Error::ValidationError` at the `input` call | input name |
165
- | Contract declared, dry-validation missing | `LoadError` at declaration | install instruction (existing message) |
166
-
167
- A raised `InputValidationError` reaches the caller as a `Failure` carrying `validation_errors`,
168
- after completed steps are rolled back — the existing path in
169
- `Executor::ResultHandler#handle_execution_error`. `have_validation_error(:field)` matches it
170
- unchanged.
171
-
172
- ## 9. Presence semantics
173
-
174
- A value is "provided" when its key exists, never when it is truthy.
175
-
176
- | Supplied | Required input | Optional input with `default:` |
177
- |---|---|---|
178
- | `false` | ✅ passes, step receives `false` | keeps `false`, default not applied |
179
- | `0`, `""`, `[]` | ✅ passes | value kept |
180
- | `nil` | ❌ "must be filled" | default applied |
181
- | key absent | ❌ "is missing" | default applied |
182
-
183
- Holds for values sourced from reactor inputs, prior step results, and nested paths within
184
- either.
185
-
186
- ## 10. Compatibility
187
-
188
- - Additive: every existing reactor and step class compiles and behaves identically.
189
- - `argument` with types/predicates and `validate_args` keep working for steps that declare no
190
- contract; deprecated in favor of a step-owned contract, removal no earlier than the next
191
- MAJOR.
192
- - Falsey-value resolution changes for anyone who relied on `false` arriving as `nil` — a bug
193
- fix, recorded under Bug Fixes in the changelog.
@@ -1,115 +0,0 @@
1
- # Phase 1 Data Model: Step Input Contracts
2
-
3
- **Feature**: `specs/002-step-input-contracts/` | **Date**: 2026-09-10
4
-
5
- Everything here is definition-time state held on Ruby classes. Nothing new is persisted to
6
- Redis; resolved argument values continue to round-trip through `ContextSerializer` exactly as
7
- today.
8
-
9
- ## InputDeclaration
10
-
11
- One declared value of one unit of work. Produced by `input` in a step class or inside an
12
- inline step's `inputs do` block.
13
-
14
- | Field | Type | Default | Notes |
15
- |---|---|---|---|
16
- | `name` | Symbol | — | Required. Unique within a contract; a redeclaration replaces the earlier one. |
17
- | `type` | Symbol \| Module \| nil | `nil` | `:string`/`:integer`/`:decimal`… → dry-schema positional type. A Module → `type?: Klass` instance check. `nil` → no type constraint. |
18
- | `optional` | Boolean | `false` | `false` → `required(name).filled(...)`. `true` → `optional(name).maybe(...)`. |
19
- | `default` | Object \| nil | `nil` | Applied when the key is absent or resolves to `nil`. Never applied for `false` (FR-023). Mutually meaningful only with `optional: true`. |
20
- | `redact` | Boolean | `false` | Value is masked in failures and logs (FR-015). |
21
- | `predicates` | Hash | `{}` | dry-schema predicates: `gt?`, `gteq?`, `min_size?`, `max_size?`, `included_in?`, … |
22
- | `macro_block` | Proc \| nil | `nil` | Form-2 block: `input :x do |i| i.filled(:string) end`. Bound to the value macro. |
23
- | `schema` | Object \| nil | `nil` | Form-3 pre-built schema/contract via `validate:`. |
24
-
25
- **Validation rules**: `name` must be a Symbol; exactly one of `predicates`+`type`,
26
- `macro_block`, or `schema` shapes the rule (matching the reactor's existing `input` dispatch in
27
- `Dsl::Reactor::ClassMethods#build_input_validator_for`); `default` without `optional: true`
28
- raises at declaration time.
29
-
30
- ## InputContract
31
-
32
- The full set of declarations owned by one unit of work, plus the compiled validator.
33
-
34
- | Field | Type | Notes |
35
- |---|---|---|
36
- | `owner` | Class \| step name | The step class, or the inline step's name. |
37
- | `declarations` | Ordered Hash{Symbol → InputDeclaration} | Declaration order preserved for message stability. |
38
- | `cross_field_block` | Proc \| nil | Cross-field rules over the whole argument hash (FR-002). Composed last, wins on conflict — same precedence as today's `validate_args`. |
39
- | `validator` | `Validation::InputValidator` | Compiled once, lazily, via `SchemaBuilder.build_args(inline_rules, cross_field_block)`. |
40
-
41
- **Derived queries** (the introspection surface, FR-014):
42
-
43
- - `required_names` → declarations where `optional == false`
44
- - `optional_names`, `defaults`, `redacted_names`
45
- - `declares?(name)`
46
-
47
- **Inheritance**: a subclass's contract is `parent.declarations.merge(own.declarations)` — same
48
- name in the subclass replaces the parent's entry (spec Edge Cases). Resolved by walking the
49
- superclass chain at first access, memoized per class.
50
-
51
- **State**: `declared` (during class body) → `compiled` (first validation or first
52
- introspection) → immutable. A declaration added after compilation resets to `declared`;
53
- supported so reopened classes behave predictably, not an encouraged pattern.
54
-
55
- ## ArgumentWiring
56
-
57
- The reactor-side binding. Already exists as the entries of `StepConfig#arguments`; this
58
- feature narrows its meaning to source + transform only.
59
-
60
- | Field | Type | Notes |
61
- |---|---|---|
62
- | `name` | Symbol | Must match an `InputDeclaration#name` when the step owns a contract (FR-018). |
63
- | `source` | `Template::Input` \| `Template::Result` \| `Template::Value` \| `Template::Element` | Unchanged. Also carries dependency information for the DAG. |
64
- | `transform` | Proc \| nil | Unchanged. Applied after resolution, before validation. |
65
- | `origin` | `:explicit` \| `:inferred` | New. `:inferred` marks a wiring synthesized by the name-based fallback (FR-020), so error messages and the dashboard can say where it came from. |
66
-
67
- **Rules**: an `:explicit` wiring is never replaced by an `:inferred` one. Rules and types on an
68
- `argument` are rejected when the step owns a contract (FR-006); still accepted, with a
69
- deprecation notice, when it does not (FR-010).
70
-
71
- ## ContractCheckResult
72
-
73
- Definition-time diagnostics. Not persisted — raised as errors.
74
-
75
- | Check | When | Raises | FR |
76
- |---|---|---|---|
77
- | Rules declared in both places | `step` macro (`StepBuilder#build`) | `Error::ValidationError` naming reactor, step, argument, owning class | FR-006 |
78
- | `argument` for an undeclared input | `step` macro | `Error::ValidationError` naming the unknown argument | FR-018 |
79
- | Required input neither wired nor name-matched | `Reactor.validate_definition!` | `Error::ValidationError` naming step, input, and both ways to satisfy it | FR-008, FR-021 |
80
- | `default` on a required input | `input` call | `Error::ValidationError` | — |
81
-
82
- ## Validation failure payload
83
-
84
- Unchanged shape — this feature adds sources, not structures. `build_validation_failure`
85
- (`executor/result_handler.rb`) already emits:
86
-
87
- | Field | Source |
88
- |---|---|
89
- | `validation_errors` | `InputValidator#format_errors` — flattened `{field => message}` |
90
- | `step_name` | Stamped by `StepExecutor` for class steps (new, D3); set at the raise site for inline steps (existing) |
91
- | `reactor_name` | Existing |
92
- | `step_arguments` | Existing; redacted per `InputDeclaration#redact` |
93
-
94
- `have_validation_error(:field)` reads `validation_errors` and therefore works against
95
- contract failures with no matcher change.
96
-
97
- ## Lifecycle
98
-
99
- ```text
100
- class body input :amount, :decimal, gt?: 0 → InputDeclaration
101
- ──────────────────────────────── appended to InputContract (declared)
102
-
103
- reactor body step :charge, ChargeStep do
104
- argument :amount, input(:amount) → ArgumentWiring(:explicit)
105
- end
106
- └─ StepBuilder#build ─────────────→ ContractCheckResult (conflict, unknown arg)
107
-
108
- first execution Reactor.validate_definition! → ContractCheckResult (satisfiability)
109
- → ArgumentWiring(:inferred) for unwired
110
- declared inputs matching reactor inputs
111
-
112
- per step run resolve_arguments → Hash{name => value} (presence-preserving)
113
- contract.validator.call(args) → Success | raise InputValidationError
114
- step body → Result
115
- ```