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.
- checksums.yaml +4 -4
- data/.claude/skills/speckit-review/SKILL.md +324 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/extensions.yml +10 -0
- data/.specify/feature.json +1 -1
- data/.specify/workflows/speckit/workflow.yml +13 -1
- data/.specify/workflows/workflow-registry.json +2 -2
- data/CHANGELOG.md +94 -0
- data/CLAUDE.md +2 -2
- data/README.md +128 -93
- data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
- data/lib/ruby_reactor/context.rb +26 -0
- data/lib/ruby_reactor/context_serializer.rb +4 -2
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +3 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
- data/lib/ruby_reactor/dsl/lockable.rb +76 -21
- data/lib/ruby_reactor/dsl/reactor.rb +8 -11
- data/lib/ruby_reactor/dsl/retryable.rb +45 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +150 -13
- data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
- data/lib/ruby_reactor/error/execution_parked.rb +16 -0
- data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
- data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
- data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
- data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
- data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
- data/lib/ruby_reactor/executor/result_handler.rb +34 -13
- data/lib/ruby_reactor/executor/retry_manager.rb +20 -13
- data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
- data/lib/ruby_reactor/executor/step_executor.rb +115 -11
- data/lib/ruby_reactor/executor.rb +90 -20
- data/lib/ruby_reactor/map/element_executor.rb +24 -2
- data/lib/ruby_reactor/map/helpers.rb +35 -11
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
- data/lib/ruby_reactor/open_telemetry.rb +61 -24
- data/lib/ruby_reactor/retry_context.rb +31 -2
- data/lib/ruby_reactor/rspec/helpers.rb +15 -0
- data/lib/ruby_reactor/rspec/matchers.rb +92 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +9 -5
- data/lib/ruby_reactor/step/async_reactor_step.rb +46 -26
- data/lib/ruby_reactor/step/compose_step.rb +22 -7
- data/lib/ruby_reactor/step/input_contract.rb +5 -0
- data/lib/ruby_reactor/step/inputs.rb +57 -0
- data/lib/ruby_reactor/step/map_step.rb +32 -22
- data/lib/ruby_reactor/step.rb +59 -11
- data/lib/ruby_reactor/step_sweeper.rb +29 -1
- data/lib/ruby_reactor/step_worker.rb +260 -37
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +72 -7
- data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
- data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
- data/lib/ruby_reactor/web/public/index.html +1 -1
- data/lib/ruby_reactor/worker.rb +56 -30
- data/lib/ruby_reactor.rb +30 -6
- data/specs/{004-inheritable-step-class → 006-step-retry-declarations}/checklists/requirements.md +10 -9
- data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
- data/specs/006-step-retry-declarations/data-model.md +58 -0
- data/specs/006-step-retry-declarations/plan.md +187 -0
- data/specs/006-step-retry-declarations/quickstart.md +80 -0
- data/specs/006-step-retry-declarations/research.md +194 -0
- data/specs/006-step-retry-declarations/spec.md +453 -0
- data/specs/006-step-retry-declarations/tasks.md +382 -0
- data/specs/future_improvements.md +250 -0
- data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
- metadata +20 -28
- data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
- data/specs/002-step-input-contracts/data-model.md +0 -115
- data/specs/002-step-input-contracts/plan.md +0 -165
- data/specs/002-step-input-contracts/quickstart.md +0 -170
- data/specs/002-step-input-contracts/research.md +0 -233
- data/specs/002-step-input-contracts/spec.md +0 -359
- data/specs/002-step-input-contracts/tasks.md +0 -367
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
- data/specs/004-inheritable-step-class/data-model.md +0 -116
- data/specs/004-inheritable-step-class/plan.md +0 -174
- data/specs/004-inheritable-step-class/quickstart.md +0 -112
- data/specs/004-inheritable-step-class/research.md +0 -308
- data/specs/004-inheritable-step-class/spec.md +0 -316
- data/specs/004-inheritable-step-class/tasks.md +0 -258
- data/specs/active_job.md +0 -259
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
- data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
- data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
- data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
- data/specs/deferred-003-step-lock-declarations/research.md +0 -196
- data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
- data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
- 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.
|
|
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-
|
|
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/
|
|
267
|
-
- specs/
|
|
268
|
-
- specs/
|
|
269
|
-
- specs/
|
|
270
|
-
- specs/
|
|
271
|
-
- specs/
|
|
272
|
-
- specs/
|
|
273
|
-
- specs/
|
|
274
|
-
- specs/
|
|
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
|
-
```
|