ruby_reactor 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +26 -16
- data/.specify/templates/plan-template.md +4 -0
- data/.specify/templates/tasks-template.md +1 -1
- data/CHANGELOG.md +140 -0
- data/CLAUDE.md +1 -1
- data/README.md +125 -31
- data/lib/ruby_reactor/context.rb +2 -2
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
- data/lib/ruby_reactor/dsl/reactor.rb +36 -18
- data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
- data/lib/ruby_reactor/dsl/template_helpers.rb +2 -2
- data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
- data/lib/ruby_reactor/executor/result_handler.rb +9 -3
- data/lib/ruby_reactor/executor/retry_manager.rb +2 -1
- data/lib/ruby_reactor/executor/step_executor.rb +9 -2
- data/lib/ruby_reactor/executor.rb +3 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
- data/lib/ruby_reactor/reactor.rb +9 -12
- data/lib/ruby_reactor/rspec/matchers.rb +3 -6
- data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
- data/lib/ruby_reactor/step/compose_step.rb +56 -75
- data/lib/ruby_reactor/step/input_contract.rb +128 -0
- data/lib/ruby_reactor/step/map_step.rb +177 -218
- data/lib/ruby_reactor/step.rb +116 -21
- data/lib/ruby_reactor/step_signals.rb +6 -2
- data/lib/ruby_reactor/step_worker.rb +25 -10
- data/lib/ruby_reactor/template/result.rb +9 -2
- data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor.rb +5 -2
- data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
- data/specs/002-step-input-contracts/data-model.md +115 -0
- data/specs/002-step-input-contracts/plan.md +165 -0
- data/specs/002-step-input-contracts/quickstart.md +170 -0
- data/specs/002-step-input-contracts/research.md +233 -0
- data/specs/002-step-input-contracts/spec.md +359 -0
- data/specs/002-step-input-contracts/tasks.md +367 -0
- data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
- data/specs/004-inheritable-step-class/data-model.md +116 -0
- data/specs/004-inheritable-step-class/plan.md +174 -0
- data/specs/004-inheritable-step-class/quickstart.md +112 -0
- data/specs/004-inheritable-step-class/research.md +308 -0
- data/specs/004-inheritable-step-class/spec.md +316 -0
- data/specs/004-inheritable-step-class/tasks.md +258 -0
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
- data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
- data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
- data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
- data/specs/deferred-003-step-lock-declarations/research.md +196 -0
- data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
- data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
- data/specs/possible_feature.md +22 -0
- metadata +28 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Implementation Plan: Step Input Contracts
|
|
2
|
+
|
|
3
|
+
**Branch**: `step_validations` | **Date**: 2026-09-10 | **Spec**: [spec.md](./spec.md)
|
|
4
|
+
|
|
5
|
+
**Input**: Feature specification from `specs/002-step-input-contracts/spec.md`
|
|
6
|
+
|
|
7
|
+
## Summary
|
|
8
|
+
|
|
9
|
+
Move validation out of the reactor and into the unit of work. A step class declares its own
|
|
10
|
+
inputs with `input :name, :type, **predicates`; an inline step declares the same lines inside
|
|
11
|
+
an `inputs do ... end` block. The reactor's `argument` keeps dependency wiring and value
|
|
12
|
+
mapping and loses rule declaration for any step that owns a contract — attempting both fails
|
|
13
|
+
at the `step` macro rather than producing two overlapping rule sets at run time.
|
|
14
|
+
|
|
15
|
+
Enforcement for class steps lives in a `run` wrapper prepended onto the step's singleton
|
|
16
|
+
class, which covers the executor, the async worker, and direct invocation with one mechanism
|
|
17
|
+
and reuses the library's existing `Error::InputValidationError` protocol (raise → rollback →
|
|
18
|
+
`build_validation_failure` → `have_validation_error`). Inline contracts compile to the
|
|
19
|
+
existing `args_validator` and close the async worker's missing validation call.
|
|
20
|
+
|
|
21
|
+
Two supporting corrections ship with it: an unwired declared input resolves from a same-named
|
|
22
|
+
reactor input (checked before execution, never from another step's result), and falsey values
|
|
23
|
+
stop being lost in transit — `Context#get_input`/`#get_result` and `Template::Result#fetch`
|
|
24
|
+
currently turn a supplied `false` into `nil`, which contracts would escalate into a spurious
|
|
25
|
+
"must be filled" failure.
|
|
26
|
+
|
|
27
|
+
Design decisions and the evidence behind them: [research.md](./research.md).
|
|
28
|
+
|
|
29
|
+
## Technical Context
|
|
30
|
+
|
|
31
|
+
**Language/Version**: Ruby >= 3.0.0
|
|
32
|
+
|
|
33
|
+
**Primary Dependencies**: dry-validation ~> 1.10 (schema construction; already a hard gem
|
|
34
|
+
dependency), sidekiq ~> 7.0 (async step path), redis ~> 5.0, zeitwerk ~> 2.6
|
|
35
|
+
|
|
36
|
+
**Storage**: Redis (unchanged by this feature — contracts are compile-time declarations;
|
|
37
|
+
resolved arguments already round-trip through `ContextSerializer`)
|
|
38
|
+
|
|
39
|
+
**Testing**: RSpec with real Redis (constitution III). Feature specs under
|
|
40
|
+
`spec/ruby_reactor/dsl/` and `spec/ruby_reactor/`; acceptance via `demo_app/` rake tasks run
|
|
41
|
+
through `docker compose run`.
|
|
42
|
+
|
|
43
|
+
**Target Platform**: Ruby library (gem), sync and Sidekiq-backed async execution
|
|
44
|
+
|
|
45
|
+
**Project Type**: Library / DSL
|
|
46
|
+
|
|
47
|
+
**Performance Goals**: Validation cost is per-step-execution and already paid today for
|
|
48
|
+
reactor-declared rules; no measurable regression. Contract compilation happens once at class
|
|
49
|
+
definition. `validate_definition!` is memoized per reactor class.
|
|
50
|
+
|
|
51
|
+
**Constraints**: Public API is SemVer-governed — additive DSL (MINOR); no existing reactor may
|
|
52
|
+
change behavior except the falsey-value fix (PATCH-class bug fix, changelog note required).
|
|
53
|
+
dry-validation stays the only validation engine.
|
|
54
|
+
|
|
55
|
+
**Scale/Scope**: ~8 library files touched, 1 new file, plus demo-app artifacts and README.
|
|
56
|
+
No new gem dependency.
|
|
57
|
+
|
|
58
|
+
## Constitution Check
|
|
59
|
+
|
|
60
|
+
*GATE: passed before Phase 0. Re-checked after Phase 1 design — see bottom of this section.*
|
|
61
|
+
|
|
62
|
+
| Principle | Assessment |
|
|
63
|
+
|---|---|
|
|
64
|
+
| **I. Gem-First Design** | ✅ Entirely inside `lib/`. No host coupling, no monkey-patching. The Sidekiq-facing change (`StepWorker`) is inside the existing adapter boundary. |
|
|
65
|
+
| **II. Saga Pattern Integrity** | ✅ Validation failures raise `Error::InputValidationError`, which `handle_execution_error` already routes through `rollback_completed_steps` before building the failure. Compensation semantics are inherited, not re-implemented. Validation runs *before* the step body, so a rejected step produces no side effect to compensate. |
|
|
66
|
+
| **III. Test-First with Real Infrastructure** | ✅ Red-Green-Refactor per task. The async-worker validation gap (research Finding 2) is exercised against real Redis + Sidekiq, not `Sidekiq::Testing.inline!`. |
|
|
67
|
+
| **IV. Observability by Default** | ✅ Failures carry reactor name, step name, redacted inputs, and field errors — `build_validation_failure` already assembles this; D3 adds the missing step attribution for class steps. FR-015 keeps `redact:` declarable on a step's own contract. |
|
|
68
|
+
| **V. Simplicity and SemVer** | ✅ MINOR: `input` on step classes and `inputs do` in step blocks are additive; reactor-declared rules keep working (D9 deprecation, not removal). The conflict error (D5) can only fire on code written after this release, since declaring a contract on a step class is not possible today. Falsey fix is a bug fix with a changelog note. Two enforcement mechanisms are justified in Complexity Tracking. |
|
|
69
|
+
| **VI. Demo-App Proof of Feature** | ✅ Planned as blocking work, not follow-up: example reactor + `demo:` rake task + spec using only the shipped matchers + `docker compose run` acceptance. `have_validation_error` already exists, so no matcher extension is expected — if a task finds an assertion it cannot express, the matcher is added to `lib/ruby_reactor/rspec/` in the same change. |
|
|
70
|
+
|
|
71
|
+
**Post-design re-check**: no new violations. The design adds no new dependency, no new
|
|
72
|
+
storage primitive, and no new failure shape — it routes a new declaration site into the
|
|
73
|
+
error protocol the library already has. The one item carried to Complexity Tracking is the
|
|
74
|
+
dual enforcement mechanism.
|
|
75
|
+
|
|
76
|
+
## Project Structure
|
|
77
|
+
|
|
78
|
+
### Documentation (this feature)
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
specs/002-step-input-contracts/
|
|
82
|
+
├── plan.md # This file
|
|
83
|
+
├── spec.md # Feature specification
|
|
84
|
+
├── research.md # Phase 0 — current-state findings and design decisions
|
|
85
|
+
├── data-model.md # Phase 1 — contract/declaration entities and lifecycle
|
|
86
|
+
├── quickstart.md # Phase 1 — how to run and verify the feature
|
|
87
|
+
├── contracts/
|
|
88
|
+
│ └── dsl-surface.md # Phase 1 — public DSL, errors, introspection API
|
|
89
|
+
├── checklists/
|
|
90
|
+
│ └── requirements.md # Spec quality checklist (complete)
|
|
91
|
+
└── tasks.md # Phase 2 — /speckit-tasks output, NOT created here
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Source Code (repository root)
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
lib/ruby_reactor/
|
|
98
|
+
├── step.rb # + `input` DSL, contract storage/inheritance,
|
|
99
|
+
│ # introspection, singleton `run` wrapper (D1, D3)
|
|
100
|
+
├── step/
|
|
101
|
+
│ └── input_contract.rb # NEW — declaration list, schema compilation,
|
|
102
|
+
│ # defaults, redaction, inheritance merge
|
|
103
|
+
├── dsl/
|
|
104
|
+
│ ├── step_builder.rb # + `inputs do` block (D2); conflict + unknown-argument
|
|
105
|
+
│ │ # errors at build time (D5)
|
|
106
|
+
│ ├── reactor.rb # + `validate_definition!` (D6), name-based
|
|
107
|
+
│ │ # fallback wiring (D7)
|
|
108
|
+
│ └── validation_helpers.rb # reused unchanged by the step-side builder
|
|
109
|
+
├── executor/
|
|
110
|
+
│ └── step_executor.rb # stamp `step_name` on re-raised validation errors (D3)
|
|
111
|
+
├── step_worker.rb # validate inline-step args; InputValidationError branch (D4)
|
|
112
|
+
├── context.rb # presence-aware get_input / get_result (D8)
|
|
113
|
+
├── template/result.rb # presence-aware nested fetch (D8)
|
|
114
|
+
├── utils/
|
|
115
|
+
│ └── fetch_indifferent.rb # NEW — the one shared presence-aware lookup (D8)
|
|
116
|
+
├── reactor.rb # call validate_definition! before execution (D6)
|
|
117
|
+
└── rspec/
|
|
118
|
+
└── test_subject.rb # call validate_definition! from test_reactor (D6)
|
|
119
|
+
|
|
120
|
+
spec/ruby_reactor/
|
|
121
|
+
├── dsl/step_input_contract_spec.rb # NEW — declaration, inheritance, introspection
|
|
122
|
+
├── dsl/step_contract_conflict_spec.rb # NEW — D5 errors, D6 satisfiability
|
|
123
|
+
├── step_contract_enforcement_spec.rb # NEW — all execution paths incl. async worker
|
|
124
|
+
└── falsey_input_resolution_spec.rb # NEW — FR-023 across inputs, results, paths
|
|
125
|
+
|
|
126
|
+
demo_app/
|
|
127
|
+
├── app/reactors/validated_signup_reactor.rb # NEW — contract-owning step, pass + fail path
|
|
128
|
+
├── lib/tasks/demo_reactors.rake # + demo:validated_signup
|
|
129
|
+
└── spec/reactors/validated_signup_reactor_spec.rb # NEW — shipped matchers only
|
|
130
|
+
|
|
131
|
+
README.md # class-step form primary, inline equivalent, migration
|
|
132
|
+
CHANGELOG.md # Features + Bug Fixes entries
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Structure Decision**: The gem's existing layout is kept as-is. Two new library files only —
|
|
136
|
+
`step/input_contract.rb` (the declaration object the spec's Key Entities describe) and
|
|
137
|
+
`utils/fetch_indifferent.rb` (one helper, three call sites). Everything else is an edit to the
|
|
138
|
+
file that already owns the concern.
|
|
139
|
+
|
|
140
|
+
## Phase 2 outline (for `/speckit-tasks`)
|
|
141
|
+
|
|
142
|
+
Dependency-ordered, each block independently testable:
|
|
143
|
+
|
|
144
|
+
1. **Falsey resolution (FR-023)** — `fetch_indifferent` + three call sites + spec. Independent
|
|
145
|
+
of everything else; land first so contract work builds on correct presence semantics.
|
|
146
|
+
2. **Contract declaration (US1, FR-001/002/013/014/015)** — `InputContract`, `Step#input`,
|
|
147
|
+
inheritance, introspection. No enforcement yet.
|
|
148
|
+
3. **Enforcement (US1, FR-003/004/022)** — prepended `run` wrapper, step-name stamping,
|
|
149
|
+
worker path. Covers class steps on every execution path.
|
|
150
|
+
4. **Reactor-side split (US2, FR-005/006/018)** — conflict and unknown-argument errors at the
|
|
151
|
+
`step` macro.
|
|
152
|
+
5. **Inline contracts (US3, FR-007)** — `inputs do` block → `args_validator`, worker
|
|
153
|
+
validation call, equivalence spec against the class form.
|
|
154
|
+
6. **Wiring resolution (US4, FR-008/020/021)** — `validate_definition!`, name-based fallback,
|
|
155
|
+
invocation from `run`/`call`/`test_reactor`.
|
|
156
|
+
7. **Back-compat + deprecation (US5, FR-010/011)** — existing-suite green, one-time notices.
|
|
157
|
+
8. **Docs + demo (FR-016/017)** — README, CHANGELOG, demo reactor + rake + spec, docker
|
|
158
|
+
acceptance run.
|
|
159
|
+
|
|
160
|
+
## Complexity Tracking
|
|
161
|
+
|
|
162
|
+
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
|
163
|
+
|-----------|------------|--------------------------------------|
|
|
164
|
+
| Two enforcement mechanisms — prepended `run` for class steps (D3), `args_validator` for inline steps (D4) | An inline step has no class to prepend to, and a class step must validate on paths the reactor does not control (`StepWorker#execute_step_body`, direct invocation). | Putting every contract into `args_validator` leaves the async worker path and direct invocation unvalidated (research Finding 2), and re-centralizes in the reactor exactly what this feature moves into the step. Both mechanisms raise the same error class through the same handler, so the observable behavior is one protocol, and an equivalence spec pins them together. |
|
|
165
|
+
| `validate_definition!` runs at first execution rather than at class load (D6) | The satisfiability check needs the reactor's complete input list, which is not available while the class body is still executing, and there is no registry of user reactor classes to sweep at boot (research Finding 7). | `TracePoint(:end)` to detect the end of a class body is unreadable and breaks on reopened classes; requiring `input` before `step` silently breaks valid existing reactors. The user-visible property is preserved — the reactor fails before step one runs, not on the run that first reaches the step. |
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Quickstart: Step Input Contracts
|
|
2
|
+
|
|
3
|
+
**Feature**: `specs/002-step-input-contracts/` | **Date**: 2026-09-10
|
|
4
|
+
|
|
5
|
+
How to run and verify this feature end to end. DSL details live in
|
|
6
|
+
[contracts/dsl-surface.md](./contracts/dsl-surface.md); design rationale in
|
|
7
|
+
[research.md](./research.md).
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- Ruby >= 3.0, `bundle install`
|
|
12
|
+
- Docker (for the gem's test Redis on port 6780 and the demo app's Redis on 6380)
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
docker compose up -d redis-test # required: spec_helper aborts without it
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Scenario 1 — a step class owns its contract (US1)
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
class ValidatedUserStep
|
|
22
|
+
include RubyReactor::Step
|
|
23
|
+
|
|
24
|
+
input :name, :string, min_size?: 2
|
|
25
|
+
input :email, :string
|
|
26
|
+
input :age, :integer, gteq?: 18
|
|
27
|
+
input :bio, :string, optional: true, default: "No bio provided", max_size?: 100
|
|
28
|
+
|
|
29
|
+
def self.run(args, _context)
|
|
30
|
+
Success(args.merge(created_at: Time.now))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class SignupReactor < RubyReactor::Reactor
|
|
35
|
+
input :name
|
|
36
|
+
input :email
|
|
37
|
+
input :age
|
|
38
|
+
|
|
39
|
+
step :profile, ValidatedUserStep # no argument block — resolved by name
|
|
40
|
+
returns :profile
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Expected**
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
SignupReactor.run(name: "Ada", email: "ada@example.com", age: 36)
|
|
48
|
+
# => Success, bio defaulted to "No bio provided"
|
|
49
|
+
|
|
50
|
+
SignupReactor.run(name: "A", email: "ada@example.com", age: 17)
|
|
51
|
+
# => Failure; validation_errors has :name and :age; ValidatedUserStep.run never called
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Verify**
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bundle exec rspec spec/ruby_reactor/dsl/step_input_contract_spec.rb
|
|
58
|
+
bundle exec rspec spec/ruby_reactor/step_contract_enforcement_spec.rb
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Scenario 2 — the reactor may not redeclare rules (US2)
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
class BadReactor < RubyReactor::Reactor
|
|
65
|
+
input :age
|
|
66
|
+
step :profile, ValidatedUserStep do
|
|
67
|
+
argument :age, input(:age), :integer, gteq?: 21 # ← rules on a contract-owning step
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
# raises RubyReactor::Error::ValidationError when the class body runs,
|
|
71
|
+
# naming BadReactor, :profile, :age, and ValidatedUserStep
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Same for `validate_args do ... end`, and for an `argument` naming an input the step never
|
|
75
|
+
declares.
|
|
76
|
+
|
|
77
|
+
**Verify**: `bundle exec rspec spec/ruby_reactor/dsl/step_contract_conflict_spec.rb`
|
|
78
|
+
|
|
79
|
+
## Scenario 3 — inline steps, same vocabulary (US3)
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
step :charge do
|
|
83
|
+
inputs do
|
|
84
|
+
input :amount, :decimal, gt?: 0
|
|
85
|
+
end
|
|
86
|
+
argument :amount, input(:amount)
|
|
87
|
+
run { |args, _| Success(charge!(args[:amount])) }
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Expected**: identical outcomes to the same declarations in a step class, for both
|
|
92
|
+
conforming and violating values (SC-005). The equivalence spec asserts this pair directly.
|
|
93
|
+
|
|
94
|
+
## Scenario 4 — missing wiring is caught before execution (US4)
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
class IncompleteReactor < RubyReactor::Reactor
|
|
98
|
+
input :name # :email and :age never declared or wired
|
|
99
|
+
step :profile, ValidatedUserStep
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
IncompleteReactor.run(name: "Ada")
|
|
103
|
+
# => RubyReactor::Error::ValidationError naming :profile, :email, and how to satisfy it.
|
|
104
|
+
# ValidatedUserStep.run is never called; no step in the reactor runs.
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Callable directly for a boot-time or CI check:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
IncompleteReactor.validate_definition!
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Scenario 5 — falsey values survive (FR-023)
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
class NotifyStep
|
|
117
|
+
include RubyReactor::Step
|
|
118
|
+
input :notify, :bool
|
|
119
|
+
def self.run(args, _ctx) = Success(args[:notify])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
SomeReactor.run(notify: false)
|
|
123
|
+
# => Success(false) — not a "must be filled" failure, and not nil
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Covers reactor inputs, prior step results, and nested paths through either.
|
|
127
|
+
|
|
128
|
+
**Verify**: `bundle exec rspec spec/ruby_reactor/falsey_input_resolution_spec.rb`
|
|
129
|
+
|
|
130
|
+
## Scenario 6 — every execution path (FR-003)
|
|
131
|
+
|
|
132
|
+
The async worker is the path that has no argument validation today
|
|
133
|
+
([research.md](./research.md) Finding 2), so it is the one worth running against real
|
|
134
|
+
infrastructure rather than `Sidekiq::Testing.inline!`:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
docker compose up -d demo-redis sidekiq
|
|
138
|
+
docker compose run --rm demo-app bin/rails demo:validated_signup
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Expected output**: the passing run prints the created profile; the failing run prints a
|
|
142
|
+
validation failure naming the step and the offending fields; the `async_step` variant shows
|
|
143
|
+
the same failure produced inside the worker.
|
|
144
|
+
|
|
145
|
+
## Full suite
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
docker compose up -d redis-test
|
|
149
|
+
bundle exec rspec # gem suite
|
|
150
|
+
bundle exec rubocop # required by the constitution, no --disable-pending-cops
|
|
151
|
+
|
|
152
|
+
docker compose run --rm demo-app bundle exec rspec spec/reactors/validated_signup_reactor_spec.rb
|
|
153
|
+
docker compose run --rm demo-app bin/rails demo:validated_signup
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Acceptance checklist
|
|
157
|
+
|
|
158
|
+
| # | Claim | How it is verified |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| SC-001 | Contract readable from the step class alone | No demo reactor declares a rule for a contract-owning step |
|
|
161
|
+
| SC-002 | Same step, same rules in every reactor | Two reactors reuse `ValidatedUserStep`, zero per-reactor rules |
|
|
162
|
+
| SC-003 | Conflicts reported at definition | Scenario 2 raises when the class body runs |
|
|
163
|
+
| SC-004 | Unwired required inputs reported before execution | Scenario 4 |
|
|
164
|
+
| SC-005 | Inline ↔ class equivalence | Scenario 3 equivalence spec |
|
|
165
|
+
| SC-006 | Existing behavior preserved | Full gem suite green |
|
|
166
|
+
| SC-007 | Failure names reactor, step, fields | `have_validation_error` + failure payload assertions |
|
|
167
|
+
| SC-008 | Demo runs in docker, both paths | Scenario 6 |
|
|
168
|
+
| SC-009 | Name-matched reactors need no arguments | Scenario 1 has no argument block |
|
|
169
|
+
| SC-010 | Direct call ≡ reactor call | `ValidatedUserStep.run({age: 17}, ctx)` raises the same error |
|
|
170
|
+
| SC-011 | `false` arrives as `false` | Scenario 5 |
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# Phase 0 Research: Step Input Contracts
|
|
2
|
+
|
|
3
|
+
**Feature**: `specs/002-step-input-contracts/` | **Date**: 2026-09-10
|
|
4
|
+
|
|
5
|
+
All findings below come from reading the current implementation, not from assumption. File
|
|
6
|
+
references are to the state of `step_validations` at the time of writing.
|
|
7
|
+
|
|
8
|
+
## Current state (what exists today)
|
|
9
|
+
|
|
10
|
+
| Concern | Where it lives now |
|
|
11
|
+
|---|---|
|
|
12
|
+
| Step argument wiring + rules | `Dsl::StepBuilder#argument` (`lib/ruby_reactor/dsl/step_builder.rb:41`) — one call does source mapping, transform, type, and predicates |
|
|
13
|
+
| Cross-field rules | `Dsl::StepBuilder#validate_args` (`step_builder.rb:76`) |
|
|
14
|
+
| Schema construction | `Validation::SchemaBuilder` (`build_inline`, `build_args`, `apply_inline_rules`) |
|
|
15
|
+
| Enforcement (inline path) | `Executor::StepExecutor#validate_step_arguments` (`step_executor.rb:312`) — **raises** `Error::InputValidationError` |
|
|
16
|
+
| Failure shaping | `Executor::ResultHandler#handle_execution_error` (`result_handler.rb:39`) — rollback + `build_validation_failure` |
|
|
17
|
+
| Step classes | `RubyReactor::Step` (`lib/ruby_reactor/step.rb`) — 43 lines: result helpers plus `run`/`compensate`/`undo` stubs. **No declaration DSL at all.** |
|
|
18
|
+
|
|
19
|
+
### Finding 1 — class steps have no contract surface and no implicit inputs
|
|
20
|
+
|
|
21
|
+
`resolve_arguments` builds only from `step_config.arguments`. `run_step_implementation`
|
|
22
|
+
(`step_executor.rb:346-353`) falls back to `@context.inputs` **only when the step has a run
|
|
23
|
+
block**. A class step with no `argument` declarations receives `{}`. FR-020's name-based
|
|
24
|
+
fallback is therefore new behavior for class steps, not a preserved one.
|
|
25
|
+
|
|
26
|
+
### Finding 2 — there are two step execution paths, and only one validates
|
|
27
|
+
|
|
28
|
+
- Inline/retry/resume: `execute_step` → `execute_step_with_retry` → `safe_execute_step_sync`
|
|
29
|
+
→ `execute_step_sync_without_result_handling` → `validate_step_arguments`. ✅
|
|
30
|
+
- `async_step` worker: `StepWorker#execute_step_body` (`step_worker.rb:112-118`) calls
|
|
31
|
+
`step_config.run_block.call` / `step_config.impl.run` directly. **No validation.** ❌
|
|
32
|
+
|
|
33
|
+
FR-003 ("enforced on every execution path") is not satisfiable by adding rules to
|
|
34
|
+
`step_config` alone.
|
|
35
|
+
|
|
36
|
+
### Finding 3 — raising is the established validation protocol
|
|
37
|
+
|
|
38
|
+
`safe_execute_step_sync` (`step_executor.rb:186`) explicitly re-raises
|
|
39
|
+
`Error::InputValidationError` so it is never retried and never wrapped as a generic step
|
|
40
|
+
failure. `handle_execution_error` then rolls back completed steps and calls
|
|
41
|
+
`build_validation_failure`, producing the `validation_errors` payload that
|
|
42
|
+
`have_validation_error` (`rspec/matchers.rb:154`) reads. Any new enforcement point that
|
|
43
|
+
raises this error inherits the correct failure shape, saga rollback, retry suppression, and
|
|
44
|
+
matcher support for free.
|
|
45
|
+
|
|
46
|
+
### Finding 4 — `input` is already overloaded, and collides inside step blocks
|
|
47
|
+
|
|
48
|
+
- On a reactor body, `Dsl::Reactor::ClassMethods#input` (`dsl/reactor.rb:70`) **declares**.
|
|
49
|
+
- Inside `step ... do ... end`, `StepBuilder` includes `Dsl::TemplateHelpers`, whose
|
|
50
|
+
`input(name, path = nil)` (`template_helpers.rb:8`) **returns a `Template::Input`** used as
|
|
51
|
+
an argument source.
|
|
52
|
+
|
|
53
|
+
So `input` means "declare" in one scope and "reference" in an adjacent one. Any inline-step
|
|
54
|
+
contract syntax must not make that worse. This is the single biggest design constraint on
|
|
55
|
+
FR-007/FR-012.
|
|
56
|
+
|
|
57
|
+
### Finding 5 — falsey values are lost before a step sees them
|
|
58
|
+
|
|
59
|
+
`Context#get_input` (`context.rb:67`) and `#get_result` (`context.rb:79`) both use
|
|
60
|
+
`@inputs[name.to_sym] || @inputs[name.to_s]`; `Template::Result#fetch`
|
|
61
|
+
(`template/result.rb:178`) repeats the pattern for nested lookups. A supplied `false`
|
|
62
|
+
resolves to `nil`. Under contracts this escalates from a silent wrong value into a spurious
|
|
63
|
+
"must be filled" failure (FR-023).
|
|
64
|
+
|
|
65
|
+
### Finding 6 — internal steps include `RubyReactor::Step`
|
|
66
|
+
|
|
67
|
+
`MapStep`, `ComposeStep`, and `AsyncReactorStep` all `include RubyReactor::Step`. Anything
|
|
68
|
+
added to that module must be inert for a step that declares no contract.
|
|
69
|
+
|
|
70
|
+
### Finding 7 — no registry of user reactor classes
|
|
71
|
+
|
|
72
|
+
`Registry` (`lib/ruby_reactor/registry.rb`) holds only dynamically-generated reactors from
|
|
73
|
+
inline `map`/`compose`. There is no list of all user-defined reactor classes, so there is no
|
|
74
|
+
place to hang a global "validate every reactor at boot" pass without adding one.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Decisions
|
|
79
|
+
|
|
80
|
+
### D1 — A contract is declared with `input` on the step class
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
class ChargeStep
|
|
84
|
+
include RubyReactor::Step
|
|
85
|
+
|
|
86
|
+
input :amount, :decimal, gt?: 0
|
|
87
|
+
input :currency, :string, included_in?: %w[USD EUR GBP]
|
|
88
|
+
input :user, User
|
|
89
|
+
input :note, :string, optional: true, max_size?: 100
|
|
90
|
+
|
|
91
|
+
def self.run(args, context) = Success(charge!(args))
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Rationale**: matches the user's sketch and the reactor's own `input`. No collision exists in
|
|
96
|
+
a step class body — `Step` does not include `TemplateHelpers`.
|
|
97
|
+
|
|
98
|
+
**Signature** is deliberately identical to `Dsl::Reactor::ClassMethods#input`:
|
|
99
|
+
`input(name, type = nil, optional: false, default: nil, redact: false, **predicates, &block)`,
|
|
100
|
+
including the Form-2 macro block (`input :x do |i| ... end`) and `validate:` for a pre-built
|
|
101
|
+
schema. Reuses `Dsl::ValidationHelpers` verbatim.
|
|
102
|
+
|
|
103
|
+
**Alternatives rejected**: `accepts` / `param` — a third word for a concept the library
|
|
104
|
+
already names twice.
|
|
105
|
+
|
|
106
|
+
### D2 — Inline steps declare a contract inside an `inputs do ... end` block
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
step :charge do
|
|
110
|
+
inputs do
|
|
111
|
+
input :amount, :decimal, gt?: 0
|
|
112
|
+
input :currency, :string, included_in?: %w[USD EUR GBP]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
argument :amount, input(:amount) # still the template reference
|
|
116
|
+
argument :currency, input(:currency)
|
|
117
|
+
|
|
118
|
+
run { |args, _| charge!(args) }
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Rationale**: Finding 4. Inside the `inputs` block, `self` is a contract builder where
|
|
123
|
+
`input` unambiguously declares; outside it, `input(:x)` keeps meaning the template reference
|
|
124
|
+
it has always meant. Zero back-compat risk, no arity magic, and the declaration lines are
|
|
125
|
+
byte-identical to the ones in a step class — moving an inline step into a class is deleting
|
|
126
|
+
the wrapper (FR-007, SC-005).
|
|
127
|
+
|
|
128
|
+
**Alternatives rejected**:
|
|
129
|
+
|
|
130
|
+
- *Arity overload* — `input(:x)` returns a template, `input(:x, :string)` declares. Tempting
|
|
131
|
+
(no extra nesting) but `input :x` as a bare statement becomes silently meaningless, and the
|
|
132
|
+
same token in the same block would mean two different things depending on argument count.
|
|
133
|
+
This is exactly the "strange bugs and hard-to-debug validation errors" the spec exists to
|
|
134
|
+
remove.
|
|
135
|
+
- *Renaming the template helper* to `reactor_input(:x)` — breaks every existing reactor.
|
|
136
|
+
|
|
137
|
+
### D3 — Enforcement lives in the step, via a prepended `run`
|
|
138
|
+
|
|
139
|
+
`Step.included(base)` prepends a wrapper onto `base.singleton_class`. The wrapper validates
|
|
140
|
+
the arguments against the contract and then calls `super`. A step with no contract skips
|
|
141
|
+
straight to `super` (Finding 6).
|
|
142
|
+
|
|
143
|
+
**Rationale**: one enforcement point covers all three call sites — the executor
|
|
144
|
+
(`step_executor.rb:353`), the async worker (`step_worker.rb:118`, Finding 2), and direct
|
|
145
|
+
invocation (FR-022) — instead of three. `TestSubject`'s mock wrapper
|
|
146
|
+
(`rspec/test_subject.rb:647`) calls `impl.run`, so mocked steps validate too.
|
|
147
|
+
|
|
148
|
+
On violation the wrapper **raises** `Error::InputValidationError` (Finding 3), not a `Failure`
|
|
149
|
+
— that is the protocol the executor, the rollback path, and `have_validation_error` already
|
|
150
|
+
speak.
|
|
151
|
+
|
|
152
|
+
**Step attribution**: the step class knows its own name but not the reactor's step name.
|
|
153
|
+
`safe_execute_step_sync`'s existing `rescue Error::InputValidationError` (`step_executor.rb:186`)
|
|
154
|
+
gains `e.step_name ||= step_config.name` before the re-raise — attribution stamped where the
|
|
155
|
+
name is known.
|
|
156
|
+
|
|
157
|
+
**Alternatives rejected**: building the step's validator into `StepConfig#args_validator` at
|
|
158
|
+
DSL time. Simpler-looking, but leaves the worker path unvalidated and direct invocation
|
|
159
|
+
unvalidated, and re-centralizes in the reactor what this feature is trying to move into the
|
|
160
|
+
step.
|
|
161
|
+
|
|
162
|
+
### D4 — Inline contracts reuse `args_validator`; the worker path gets the missing call
|
|
163
|
+
|
|
164
|
+
An inline step has no class to prepend to, so its `inputs` block compiles to an
|
|
165
|
+
`args_validator` on `StepConfig`, enforced by the existing `validate_step_arguments`. To
|
|
166
|
+
close Finding 2, `StepWorker#execute_step_body` gains the same validation call, and its
|
|
167
|
+
`rescue StandardError` grows an `Error::InputValidationError` branch so the worker produces
|
|
168
|
+
the same failure shape rather than a generic `Failure(e)`.
|
|
169
|
+
|
|
170
|
+
Two mechanisms, one protocol: both raise `Error::InputValidationError`, both land in
|
|
171
|
+
`build_validation_failure`.
|
|
172
|
+
|
|
173
|
+
### D5 — Reactor-side conflicts fail at the `step` macro
|
|
174
|
+
|
|
175
|
+
`StepBuilder#build` already has both `@impl` and `@arg_validations`. When `@impl` declares a
|
|
176
|
+
contract and the reactor supplied a type, predicates, `validate_args`, or an `inputs` block,
|
|
177
|
+
raise immediately — the error points at the offending line in the reactor class body
|
|
178
|
+
(FR-006). Same for an `argument` naming an input the contract does not declare (FR-018).
|
|
179
|
+
|
|
180
|
+
Message names the reactor, the step, the argument, and the owning step class.
|
|
181
|
+
|
|
182
|
+
### D6 — Satisfiability is checked by `validate_definition!`, memoized, at first execution
|
|
183
|
+
|
|
184
|
+
FR-021 needs the reactor's full input list, which is not known while the class body is still
|
|
185
|
+
executing — `input` declarations may follow `step` declarations. Finding 7 rules out a
|
|
186
|
+
global boot-time sweep.
|
|
187
|
+
|
|
188
|
+
`Reactor.validate_definition!` walks every step with a contract and asserts each required
|
|
189
|
+
input is satisfied by an explicit `argument` or a same-named reactor input. It is memoized
|
|
190
|
+
and invoked from `Reactor.run`/`.call` before execution begins, and from `test_reactor`, and
|
|
191
|
+
is public so an application can call it in an initializer or CI check.
|
|
192
|
+
|
|
193
|
+
**Deviation from spec wording**: FR-008/FR-021 and US4 say "when the reactor class is
|
|
194
|
+
loaded". Conflict and unknown-argument checks (D5) genuinely are load-time. The
|
|
195
|
+
satisfiability check fires at first execution instead. The user-visible property the spec
|
|
196
|
+
cares about — the error names the reactor, step, and input, and does not depend on reaching
|
|
197
|
+
that step at run time — holds either way: a reactor whose wiring is incomplete fails before
|
|
198
|
+
step one runs, not on the unlucky run that first reaches the step.
|
|
199
|
+
|
|
200
|
+
**Alternatives rejected**: `TracePoint(:end)` to detect the end of a class body (clever,
|
|
201
|
+
unreadable, breaks on reopened classes); requiring `input` before `step` (silently breaks
|
|
202
|
+
valid existing reactors).
|
|
203
|
+
|
|
204
|
+
### D7 — Name-based fallback resolves at `validate_definition!` time, reactor inputs only
|
|
205
|
+
|
|
206
|
+
An unwired declared input becomes `Template::Input.new(name)` appended to the step's
|
|
207
|
+
`arguments` — the same object an explicit `argument :x, input(:x)` produces, so nothing
|
|
208
|
+
downstream changes. Explicit wiring wins (never overwritten). Step results are never
|
|
209
|
+
consulted (FR-020), so resolution can't shift when an unrelated step is renamed.
|
|
210
|
+
|
|
211
|
+
### D8 — Presence means "supplied", via one shared helper
|
|
212
|
+
|
|
213
|
+
Add `RubyReactor::Utils.fetch_indifferent(hash, key)` —
|
|
214
|
+
`hash.key?(key.to_sym) ? hash[key.to_sym] : hash[key.to_s]` — and use it at `context.rb:67`,
|
|
215
|
+
`context.rb:79`, and `template/result.rb:178`. Defaults (FR-013) apply when the key is absent
|
|
216
|
+
or the resolved value is `nil`; `false` is neither, so it survives.
|
|
217
|
+
|
|
218
|
+
**SemVer**: a fix, not a break — no documented behavior said `false` becomes `nil`.
|
|
219
|
+
|
|
220
|
+
### D9 — Deprecation, not removal, for reactor-declared rules
|
|
221
|
+
|
|
222
|
+
`argument :x, src, :string, gt?: 0` on a step with no contract keeps working unchanged
|
|
223
|
+
(FR-010). It emits a one-time-per-site deprecation naming the `input` replacement once the
|
|
224
|
+
step's own contract is the documented path (FR-011). Removal is a later MAJOR.
|
|
225
|
+
|
|
226
|
+
## Open risks
|
|
227
|
+
|
|
228
|
+
| Risk | Mitigation |
|
|
229
|
+
|---|---|
|
|
230
|
+
| Prepending to `singleton_class` surprises anyone who aliases or redefines `self.run` after `include` | Prepend happens at `include` time, so a later `def self.run` is still `super`'d correctly. Covered by a spec. |
|
|
231
|
+
| `inputs do` block inside `step` is a third nesting level | Only for inline steps; the constitution already names class steps the preferred style. |
|
|
232
|
+
| Two enforcement mechanisms (D3 prepend, D4 validator) could drift | Both raise the same error class through the same handler; a shared spec asserts identical outcomes for the class and inline forms (SC-005). |
|
|
233
|
+
| Falsey fix changes behavior for anyone relying on `false → nil` | Pre-existing defect; changelog note under Bug Fixes. |
|