ruby_reactor 0.8.2 → 0.8.3

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 (77) 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 +82 -0
  9. data/CLAUDE.md +2 -2
  10. data/README.md +35 -2
  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/interrupt_builder.rb +14 -0
  16. data/lib/ruby_reactor/dsl/lockable.rb +76 -21
  17. data/lib/ruby_reactor/dsl/step_builder.rb +112 -1
  18. data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
  19. data/lib/ruby_reactor/error/execution_parked.rb +16 -0
  20. data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
  21. data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
  22. data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
  23. data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
  24. data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
  25. data/lib/ruby_reactor/executor/result_handler.rb +31 -11
  26. data/lib/ruby_reactor/executor/retry_manager.rb +9 -1
  27. data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
  28. data/lib/ruby_reactor/executor/step_executor.rb +115 -11
  29. data/lib/ruby_reactor/executor.rb +90 -20
  30. data/lib/ruby_reactor/map/element_executor.rb +24 -2
  31. data/lib/ruby_reactor/map/helpers.rb +35 -11
  32. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
  33. data/lib/ruby_reactor/open_telemetry.rb +61 -24
  34. data/lib/ruby_reactor/retry_context.rb +31 -2
  35. data/lib/ruby_reactor/rspec/helpers.rb +15 -0
  36. data/lib/ruby_reactor/rspec/matchers.rb +92 -0
  37. data/lib/ruby_reactor/rspec/test_subject.rb +7 -1
  38. data/lib/ruby_reactor/step/async_reactor_step.rb +40 -24
  39. data/lib/ruby_reactor/step/compose_step.rb +14 -3
  40. data/lib/ruby_reactor/step.rb +49 -7
  41. data/lib/ruby_reactor/step_sweeper.rb +29 -1
  42. data/lib/ruby_reactor/step_worker.rb +260 -37
  43. data/lib/ruby_reactor/version.rb +1 -1
  44. data/lib/ruby_reactor/web/api.rb +72 -7
  45. data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
  46. data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
  47. data/lib/ruby_reactor/web/public/index.html +1 -1
  48. data/lib/ruby_reactor/worker.rb +56 -30
  49. data/lib/ruby_reactor.rb +27 -5
  50. data/specs/future_improvements.md +250 -0
  51. metadata +8 -28
  52. data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
  53. data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
  54. data/specs/002-step-input-contracts/data-model.md +0 -115
  55. data/specs/002-step-input-contracts/plan.md +0 -165
  56. data/specs/002-step-input-contracts/quickstart.md +0 -170
  57. data/specs/002-step-input-contracts/research.md +0 -233
  58. data/specs/002-step-input-contracts/spec.md +0 -359
  59. data/specs/002-step-input-contracts/tasks.md +0 -367
  60. data/specs/004-inheritable-step-class/checklists/requirements.md +0 -40
  61. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
  62. data/specs/004-inheritable-step-class/data-model.md +0 -116
  63. data/specs/004-inheritable-step-class/plan.md +0 -174
  64. data/specs/004-inheritable-step-class/quickstart.md +0 -112
  65. data/specs/004-inheritable-step-class/research.md +0 -308
  66. data/specs/004-inheritable-step-class/spec.md +0 -316
  67. data/specs/004-inheritable-step-class/tasks.md +0 -258
  68. data/specs/active_job.md +0 -259
  69. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
  70. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
  71. data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
  72. data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
  73. data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
  74. data/specs/deferred-003-step-lock-declarations/research.md +0 -196
  75. data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
  76. data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
  77. data/specs/possible_feature.md +0 -22
@@ -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
- ```
@@ -1,165 +0,0 @@
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. |
@@ -1,170 +0,0 @@
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 |