ruby_reactor 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
- data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +92 -15
- data/.specify/templates/plan-template.md +4 -0
- data/.specify/templates/tasks-template.md +8 -1
- data/CHANGELOG.md +151 -0
- data/CLAUDE.md +2 -2
- data/README.md +149 -40
- data/lib/ruby_reactor/context.rb +9 -2
- data/lib/ruby_reactor/context_serializer.rb +13 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
- data/lib/ruby_reactor/dsl/lockable.rb +2 -2
- 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 +13 -5
- 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/compensation_manager.rb +58 -45
- data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
- data/lib/ruby_reactor/executor/result_handler.rb +44 -13
- data/lib/ruby_reactor/executor/retry_manager.rb +6 -2
- data/lib/ruby_reactor/executor/step_executor.rb +27 -14
- data/lib/ruby_reactor/executor.rb +20 -15
- data/lib/ruby_reactor/map/element_executor.rb +7 -1
- data/lib/ruby_reactor/map/helpers.rb +9 -7
- data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
- data/lib/ruby_reactor/map/sweeper.rb +1 -1
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
- data/lib/ruby_reactor/open_telemetry.rb +7 -4
- data/lib/ruby_reactor/ordered_lock.rb +3 -3
- data/lib/ruby_reactor/reactor.rb +9 -12
- data/lib/ruby_reactor/rspec/matchers.rb +64 -17
- data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
- 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 +178 -215
- data/lib/ruby_reactor/step.rb +119 -18
- data/lib/ruby_reactor/step_signals.rb +37 -0
- data/lib/ruby_reactor/step_worker.rb +25 -10
- data/lib/ruby_reactor/storage/adapter.rb +4 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
- data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
- 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/web/api.rb +23 -6
- data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
- data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
- data/lib/ruby_reactor/web/public/index.html +2 -2
- data/lib/ruby_reactor.rb +61 -9
- 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 +34 -11
- data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
- data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
- data/specs/001-background-async-steps/checklists/requirements.md +0 -39
- data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
- data/specs/001-background-async-steps/data-model.md +0 -117
- data/specs/001-background-async-steps/plan.md +0 -168
- data/specs/001-background-async-steps/quickstart.md +0 -102
- data/specs/001-background-async-steps/research.md +0 -150
- data/specs/001-background-async-steps/spec.md +0 -146
- data/specs/001-background-async-steps/tasks.md +0 -271
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
---
|
|
2
|
+
|
|
3
|
+
description: "Task list for Step Input Contracts"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Tasks: Step Input Contracts
|
|
7
|
+
|
|
8
|
+
**Input**: Design documents from `specs/002-step-input-contracts/`
|
|
9
|
+
|
|
10
|
+
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/dsl-surface.md, quickstart.md
|
|
11
|
+
|
|
12
|
+
**Tests**: REQUIRED. Constitution III makes test-first mandatory. Write each story's specs, watch
|
|
13
|
+
them fail, then implement. Real Redis always (`docker compose up -d redis-test`). The async worker
|
|
14
|
+
claim uses the live lane (`for_each_real_async_backend` from `spec/support/real_async_backend.rb`).
|
|
15
|
+
`Sidekiq::Testing.inline!` is not allowed in these specs.
|
|
16
|
+
|
|
17
|
+
**Organization**: Grouped by user story. The plan's Phase 2 outline maps as: plan 1 → Phase 2,
|
|
18
|
+
plans 2–3 → US1, plan 4 → US2, plan 5 → US3, plan 6 → US4, plan 7 → US5, plan 8 → Polish.
|
|
19
|
+
|
|
20
|
+
## Format: `[ID] [P?] [Story] Description`
|
|
21
|
+
|
|
22
|
+
- **[P]**: Can run in parallel (different files, no dependency on an incomplete task)
|
|
23
|
+
- **[Story]**: User story the task belongs to (US1–US5)
|
|
24
|
+
|
|
25
|
+
## Findings from the code that the tasks below encode
|
|
26
|
+
|
|
27
|
+
Read these before starting. Each was found in the current code and is not in plan.md, or it
|
|
28
|
+
corrects plan.md.
|
|
29
|
+
|
|
30
|
+
1. **Zeitwerk naming.** `Zeitwerk::Loader.for_gem` (`lib/ruby_reactor.rb:37`) maps
|
|
31
|
+
`utils/fetch_indifferent.rb` to the constant `RubyReactor::Utils::FetchIndifferent`. research D8's
|
|
32
|
+
`RubyReactor::Utils.fetch_indifferent(...)` module function would fail eager loading. Use
|
|
33
|
+
`RubyReactor::Utils::FetchIndifferent.call(hash, key)`, which matches
|
|
34
|
+
`Utils::BacktraceLocation.parse` (T003).
|
|
35
|
+
2. **A subclass's `def self.run` skips a wrapper prepended onto its parent.** The singleton
|
|
36
|
+
ancestry of `class Child < ParentStep` is `[Child.singleton, <wrapper>, ParentStep.singleton, ...]`,
|
|
37
|
+
so `Child.run` runs before the parent's prepended wrapper. `Step::ClassMethods#inherited` has to
|
|
38
|
+
prepend the wrapper onto every subclass's singleton class too (T010). A child that calls `super`
|
|
39
|
+
validates twice. That is harmless: validation is idempotent and defaults are already applied.
|
|
40
|
+
3. **`args_validator` cannot apply defaults.** `validate_step_arguments` (`step_executor.rb:312`)
|
|
41
|
+
discards the validated value. Switching it to use `result.to_h` would change behavior for
|
|
42
|
+
existing validators, because `Dry::Schema.Params` coerces values and drops undeclared keys. So
|
|
43
|
+
inline contracts do **not** compile to `args_validator`, which supersedes research D4. They are
|
|
44
|
+
enforced by the same `InputContract#enforce!` call the class wrapper uses, at the two run-block
|
|
45
|
+
call sites (`StepExecutor#run_step_implementation`, `StepWorker#execute_step_body`). That leaves
|
|
46
|
+
one enforcement method called from three places instead of two mechanisms, and SC-005
|
|
47
|
+
equivalence holds by construction (T020, T023).
|
|
48
|
+
4. **The step receives the resolved values, never the schema's coerced output.** This matches
|
|
49
|
+
today's `validate_step_arguments`. `enforce!` returns `args` merged with defaults, not
|
|
50
|
+
`result.to_h`.
|
|
51
|
+
5. **The worker process needs the inferred wiring too.** D7 appends inferred wirings to
|
|
52
|
+
`StepConfig#arguments` inside `validate_definition!`. `StepWorker` runs in a fresh process and
|
|
53
|
+
resolves arguments directly (`step_worker.rb:150`) without building an `Executor`. If nothing in
|
|
54
|
+
that process calls `validate_definition!`, a name-resolved class step receives `{}` in the
|
|
55
|
+
worker and fails with "is missing". `validate_definition!` is therefore called from
|
|
56
|
+
`Reactor#run`, `Executor#initialize` (resume, map, compose, background workers), and
|
|
57
|
+
`StepWorker#perform_unit` (T028). It is memoized, so the extra calls cost nothing.
|
|
58
|
+
6. **Step attribution is overwritten, not `||=`.** For direct invocation (FR-022), the wrapper
|
|
59
|
+
stamps `step_name` with the step class's name. Inside a reactor, the executor must then
|
|
60
|
+
**overwrite** it with the reactor's step name (`:charge`, not `"ChargeStep"`). research D3's
|
|
61
|
+
`e.step_name ||= step_config.name` would keep the class name (T012).
|
|
62
|
+
7. **`Failure` never redacts `step_arguments`.** `Failure#append_step_arguments`
|
|
63
|
+
(`lib/ruby_reactor.rb:274`) prints every value, and `run_step_implementation` writes raw
|
|
64
|
+
arguments into the execution trace, which the dashboard reads. FR-015 is met by masking at the
|
|
65
|
+
source: `enforce!` puts a redacted copy on `error.step_arguments`, and the trace entry uses
|
|
66
|
+
`input_contract.redact(arguments)` (T008, T012).
|
|
67
|
+
8. **Validation failures in the worker are retried and lose their shape.**
|
|
68
|
+
`StepWorker#execute_step_body` wraps every exception in a generic `Failure(e)`, which is
|
|
69
|
+
retryable by default, so `retry?` would loop. The new `InputValidationError` branch must pass
|
|
70
|
+
`retryable: false` and `validation_errors:` (T015).
|
|
71
|
+
9. **Fixture files load with the whole suite.** `spec_helper.rb:56` requires
|
|
72
|
+
`spec/support/**/*.rb`. A fixture that calls `input` before the DSL exists breaks loading for
|
|
73
|
+
every spec. Fixtures that use the new DSL are created only after the DSL task they depend on
|
|
74
|
+
(T013 after T009, T022 after T019). Everything else is defined inline in the spec with
|
|
75
|
+
`stub_const`/`Class.new`.
|
|
76
|
+
10. **Class steps get no implicit inputs today** (research Finding 1). Until US4 lands, US1–US3
|
|
77
|
+
specs must wire every input with an explicit `argument :x, input(:x)`.
|
|
78
|
+
11. **`InterruptBuilder < StepBuilder`** (`dsl/interrupt_builder.rb:5`). Interrupt steps would
|
|
79
|
+
inherit `inputs`, and `InterruptBuilder#build` would silently drop the contract. Override it to
|
|
80
|
+
raise (T021).
|
|
81
|
+
12. **Existing deprecation idiom:** `warn "[RubyReactor] DEPRECATION: ..."` guarded by an ivar
|
|
82
|
+
(`dsl/reactor.rb:107`, `dsl/interrupt_builder.rb:40`). Don't use `warn(category: :deprecated)`:
|
|
83
|
+
Ruby hides that category by default, which would make the FR-011 notice invisible (T030).
|
|
84
|
+
|
|
85
|
+
## Shared names used across tasks
|
|
86
|
+
|
|
87
|
+
- `RubyReactor::Step::InputContract` in `lib/ruby_reactor/step/input_contract.rb`. Includes
|
|
88
|
+
`Dsl::ValidationHelpers`, so step classes don't gain those helper methods.
|
|
89
|
+
- `InputContract::Declaration = Struct.new(:name, :type, :optional, :default, :redact, :predicates,
|
|
90
|
+
:macro_block, :schema, :validator, keyword_init: true)`. Use `Struct`, not `Data`: Ruby >= 3.0.
|
|
91
|
+
- `InputContract` API: `#input(name, type = nil, optional: false, default: nil, redact: false,
|
|
92
|
+
validate: nil, **predicates, &block)`, `#validate_inputs(schema = nil, &block)`, `#declarations`
|
|
93
|
+
(ordered `Hash{Symbol => Declaration}`), `#declares?(name)`, `#required_names`, `#optional_names`,
|
|
94
|
+
`#defaults`, `#redacted_names`, `#empty?`, `#merge(child)` (returns a new contract, used for
|
|
95
|
+
inheritance), `#redact(args)` (copy with redacted names → `"[REDACTED]"`), and
|
|
96
|
+
`#enforce!(args)`. `enforce!` returns `args` plus defaults, or raises
|
|
97
|
+
`Error::InputValidationError` with `field_errors`, `step_arguments = redact(args)`, and
|
|
98
|
+
`step_name` left for the caller to stamp.
|
|
99
|
+
- Step class API: `input`, `validate_inputs`, `input_contract`, `declared_inputs`,
|
|
100
|
+
`required_input_names`, `declares_inputs?`. `RubyReactor::Step::InputEnforcement` is the module
|
|
101
|
+
prepended onto the singleton class.
|
|
102
|
+
- `StepConfig#inline_contract` (from `inputs do`, nil otherwise) and `StepConfig#input_contract`
|
|
103
|
+
(`inline_contract` if present, else `impl.input_contract` when `impl` declares inputs, else nil).
|
|
104
|
+
- `StepConfig#arguments` entries gain `origin:` (`:explicit` from `argument`, `:inferred` from
|
|
105
|
+
name-based resolution).
|
|
106
|
+
- `RubyReactor::Utils::FetchIndifferent.call(hash, key)` returns
|
|
107
|
+
`hash.key?(key.to_sym) ? hash[key.to_sym] : hash[key.to_s]`.
|
|
108
|
+
- Fixture file for the live worker: `spec/support/reactors/step_contract_reactors.rb`.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Phase 1: Setup (Shared Infrastructure)
|
|
113
|
+
|
|
114
|
+
**Purpose**: Record where the suite stands before anything changes
|
|
115
|
+
|
|
116
|
+
- [X] T001 Run `docker compose up -d redis-test`, then `bundle exec rspec` and `bundle exec rubocop` on the untouched branch. Save the example/failure/pending counts and the rubocop offense count to `specs/002-step-input-contracts/baseline.txt`. SC-006 is checked against this file at the end (T031, T041)
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Phase 2: Foundational (Blocking Prerequisites)
|
|
121
|
+
|
|
122
|
+
**Purpose**: Presence-correct value resolution (FR-023) and one shared validator dispatch. US1 AS6
|
|
123
|
+
and every default depend on the first; `InputContract` depends on the second.
|
|
124
|
+
|
|
125
|
+
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
|
126
|
+
|
|
127
|
+
- [X] T002 [P] Write `spec/ruby_reactor/falsey_input_resolution_spec.rb` (red). Cases, each asserting the step body receives exactly `false`, not nil: (a) reactor input `flag: false` → inline step `argument :flag, input(:flag)`; (b) same reactor input → class step `ReceivesFlag` (plain `include RubyReactor::Step`, no contract, defined with `stub_const`); (c) prior step returns `false`, next step wires `argument :flag, result(:first)`; (d) nested paths `input(:config, :notify)`, `input(:config, [:a, :notify])`, `result(:first, :flag)` with a `false` leaf; (e) `RubyReactor::Context#get_input` and `#get_result` for a symbol-keyed `false` and a string-keyed `false`; (f) the private `Template::Result#fetch` (call via `send`) on `{ success: false }` and `{ "success" => false }`; (g) `ContextSerializer.deserialize(ContextSerializer.serialize(ctx)).get_input(:flag) == false` for the serialization round trip. Also assert `0`, `""`, `[]` survive in (a)
|
|
128
|
+
- [X] T003 [P] Create `lib/ruby_reactor/utils/fetch_indifferent.rb` defining `RubyReactor::Utils::FetchIndifferent` with `def self.call(hash, key) = hash.key?(key.to_sym) ? hash[key.to_sym] : hash[key.to_s]`. The class name follows Zeitwerk (Finding 1). No spec of its own: T002 covers it
|
|
129
|
+
- [X] T004 Replace the `a || b` lookups with `Utils::FetchIndifferent.call` in `RubyReactor::Context#get_input` (`lib/ruby_reactor/context.rb:67`), `#get_result` (`context.rb:79`), and `Template::Result#fetch` (`lib/ruby_reactor/template/result.rb:177`). Keep `return nil if value.nil?` as is. T002 goes green and the rest of the suite stays at baseline (depends on T003)
|
|
130
|
+
- [X] T005 [P] Move the form dispatch out of `Dsl::Reactor::ClassMethods#build_input_validator_for` (`lib/ruby_reactor/dsl/reactor.rb:91`) into a public `build_declaration_validator(name, type, optional, validate, predicates, &block)` in `lib/ruby_reactor/dsl/validation_helpers.rb`. The reactor method keeps only its reactor-specific part, `warn_deprecated_input_block` when `block&.arity&.zero?`, and delegates the rest. This is a pure refactor: `spec/ruby_reactor/validations_spec.rb` must stay green unchanged (FR-009)
|
|
131
|
+
|
|
132
|
+
**Checkpoint**: `false` reaches steps intact; one validator dispatch serves reactor inputs and, next, step contracts
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Phase 3: User Story 1 - A step class declares its own input contract (Priority: P1) 🎯 MVP
|
|
137
|
+
|
|
138
|
+
**Goal**: `input :x, :type, **rules` inside a step class, enforced before `run` on every
|
|
139
|
+
execution path (inline, retry, map, `background`, `async_step` worker, direct call). Failures use
|
|
140
|
+
the existing `InputValidationError` → rollback → `build_validation_failure` protocol.
|
|
141
|
+
|
|
142
|
+
**Independent Test**: A step class with a typed, bounded contract, run from a minimal reactor
|
|
143
|
+
that wires inputs with explicit `argument` lines and declares no rules. Conforming values succeed.
|
|
144
|
+
Violating values fail with `validation_errors` and `step_name`, and the body never runs.
|
|
145
|
+
|
|
146
|
+
### Tests for User Story 1 ⚠️
|
|
147
|
+
|
|
148
|
+
> Write these first and confirm they fail before implementation.
|
|
149
|
+
|
|
150
|
+
- [X] T006 [P] [US1] Write `spec/ruby_reactor/dsl/step_input_contract_spec.rb` (red), covering declaration and introspection, with fixtures defined inline via `stub_const`: Forms 0/1/1b/1-opt/2/3 from `contracts/dsl-surface.md` §1 each appear in `declared_inputs` with the right `type`/`optional`/`predicates`/`macro_block`/`schema`; `required_input_names` returns the non-optional names in declaration order; `declares_inputs?` is false for a plain step and for `RubyReactor::Step::MapStep`, `ComposeStep`, `AsyncReactorStep` (research Finding 6); redeclaring a name replaces the earlier one; inheritance: the child's contract is the parent's plus its own, a same-named child input replaces the parent's, and the parent's contract is unchanged; `validate_inputs` blocks from parent and child both apply; `input :x, default: 1` without `optional: true` raises `RubyReactor::Error::ValidationError` at the `input` call; `input` raises `LoadError` with the existing install message when `Dry::Schema` is hidden (`hide_const("Dry::Schema")`); enforcement survives (a) `def self.run` written after `include` and (b) a subclass defining its own `def self.run` (Finding 2). For (b), call `Child.run({}, ctx)` with an invalid value and expect `InputValidationError`
|
|
151
|
+
- [X] T007 [P] [US1] Write `spec/ruby_reactor/step_contract_enforcement_spec.rb` (red), covering sync execution paths with explicit `argument` wiring (Finding 10). Record body calls in a local array. Cases: US1 AS1–AS6, where AS6 is `input :notify, :bool` receiving `false`; a failure's `validation_errors` has each offending field, `step_name == :charge` (the reactor step name, Finding 6), `reactor_name` is set, and the body array is empty; `test_reactor(...)` + `have_validation_error(:amount)` matches; a completed prior step with `compensate` is rolled back (saga); a step with `retries max_attempts: 3` validates once and its body is never attempted; direct call `Step.run({ amount: 0 }, ctx)` raises `InputValidationError` with `step_name == "<StepClass>"` and the same `field_errors` as the reactor run (SC-010); `optional: true, default: "x"` applies for an absent key and for `nil`, not for `false`; `redact: true` shows `"[REDACTED]"` in `failure.step_arguments`, in `failure.message`, and in the `:run` execution-trace entry's `arguments`; a `map` over the class step fails when one element violates the contract; a step whose `where` is false produces no validation error; the same class used under two step names is validated independently for each
|
|
152
|
+
|
|
153
|
+
### Implementation for User Story 1
|
|
154
|
+
|
|
155
|
+
- [X] T008 [US1] Create `RubyReactor::Step::InputContract` in `lib/ruby_reactor/step/input_contract.rb` per Shared names. `input` calls `check_dry_validation_available!` eagerly, raises `Error::ValidationError` for `default:` without `optional: true`, stores a `Declaration`, and resets the memoized validators. The per-declaration validator comes from `build_declaration_validator` (T005). `enforce!(args)`: (1) apply defaults where the key is absent or the value is `nil` (never for `false`); (2) for each declaration with a validator, skip it when optional and the key is absent, otherwise call `validator.call({ name => value })` and merge `field_errors`, the same shape as `Dsl::Reactor::ClassMethods#validate_inputs` (`dsl/reactor.rb:182`); (3) run each `validate_inputs` block/schema (`create_input_validator`) over the whole hash, last, merging its errors over earlier ones; (4) on errors raise `Error::InputValidationError.new(errors)` with `step_arguments = redact(args)`; (5) otherwise return the args-with-defaults hash itself, not the schema output (Finding 4). `merge(child)` returns a new contract with `declarations.merge(child.declarations)` and both block lists concatenated
|
|
156
|
+
- [X] T009 [US1] In `lib/ruby_reactor/step.rb`, add to `Step::ClassMethods`: `input(...)` and `validate_inputs(...)` delegating to an own-class contract (`@own_input_contract ||= InputContract.new(owner: self)`); `input_contract` (superclass's `input_contract.merge(own)` when the superclass declares inputs, else own; memoized, and the memo is cleared by `input`/`validate_inputs`); `declared_inputs` (`input_contract.declarations`); `required_input_names`; `declares_inputs?` (`!input_contract.empty?`)
|
|
157
|
+
- [X] T010 [US1] In `lib/ruby_reactor/step.rb`, add `Step::InputEnforcement` with `def run(arguments, context)`: return `super` when `!declares_inputs?`; otherwise call `super(input_contract.enforce!(arguments), context)`, rescuing `Error::InputValidationError` to set `e.step_name = name` and re-raise. Prepend it in `Step.included(base)` (`base.singleton_class.prepend(InputEnforcement)`) and in a new `ClassMethods#inherited(subclass)` (`super` then prepend onto `subclass.singleton_class`) (Finding 2). `compensate`/`undo` are not wrapped (contract §7) (depends on T008, T009)
|
|
158
|
+
- [X] T011 [US1] In `lib/ruby_reactor/dsl/step_builder.rb`, add `StepConfig#input_contract` returning `impl.input_contract` when `impl.respond_to?(:declares_inputs?) && impl.declares_inputs?`, else nil. US3 extends it to inline contracts
|
|
159
|
+
- [X] T012 [US1] In `lib/ruby_reactor/executor/step_executor.rb`: (a) in `safe_execute_step_sync`'s `rescue Error::InputValidationError` (line 186), capture `=> e`, set `e.step_name = step_config.name` (overwrite, Finding 6) and `e.step_arguments ||= resolved_arguments`, then re-raise; (b) in `run_step_implementation`, write `arguments: step_config.input_contract ? step_config.input_contract.redact(arguments) : arguments` into the `:run` trace entry (Finding 7). T006 and T007 go green (depends on T010, T011)
|
|
160
|
+
- [X] T013 [US1] Create `spec/support/reactors/step_contract_reactors.rb` for the live worker (requires T009, Finding 9). Define `ContractChargeStep` (`input :amount, :integer, gteq?: 1`; `input :currency, :string, included_in?: %w[USD EUR]`; body returns `Success(args)`), `ContractAsyncStepReactor` (inputs `amount`, `currency`; `async_step :charge, ContractChargeStep` with explicit `argument` lines and `retries max_attempts: 3`; `step :confirm` with `argument :charge, result(:charge)` whose body returns `args[:charge]`, so a Failure from the worker becomes this reactor's failure, following `AsyncStepDemoReactor`'s reader pattern), and `ContractBackgroundReactor` (`background all: true`, one step using `ContractChargeStep`)
|
|
161
|
+
- [X] T014 [US1] Write `spec/ruby_reactor/step_contract_async_spec.rb` (red) using `for_each_real_async_backend`: `ContractAsyncStepReactor.run(amount: 0, currency: "USD")`. After the worker finishes (follow the waiting pattern in existing `for_each_real_async_backend` specs), the reactor's failure carries `validation_errors` with `:amount`, `step_name == :charge`, and `retryable == false`, and the worker attempted validation once (no retry). `amount: 5` succeeds. `ContractBackgroundReactor` with `amount: 0` fails the same way inside the hand-off worker (FR-003) (depends on T013)
|
|
162
|
+
- [X] T015 [US1] In `lib/ruby_reactor/step_worker.rb#execute_step_body`, add `rescue Error::InputValidationError => e` before the existing `rescue StandardError`, returning `RubyReactor.Failure(e, validation_errors: e.field_errors, step_name: @step_name, step_arguments: e.step_arguments || {}, reactor_name: @reactor_class_name, retryable: false)` (Finding 8). Class steps are already validated by the prepended `run`. T014 goes green (depends on T014)
|
|
163
|
+
|
|
164
|
+
**Checkpoint**: A step class owns its rules. Every execution path enforces them, and failures are attributed and redacted. MVP is shippable here.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Phase 4: User Story 2 - The reactor wires values without redeclaring rules (Priority: P1)
|
|
169
|
+
|
|
170
|
+
**Goal**: For a contract-owning step, `argument` is wiring only. Rules in the reactor, or wiring for
|
|
171
|
+
an undeclared input, fail at the `step` macro (FR-006, FR-018).
|
|
172
|
+
|
|
173
|
+
**Independent Test**: A mapping-only reactor over a contract-owning step loads and runs. Adding a
|
|
174
|
+
typed `argument` or `validate_args` makes the class body raise, and the message names the reactor,
|
|
175
|
+
step, argument, and owning class.
|
|
176
|
+
|
|
177
|
+
### Tests for User Story 2 ⚠️
|
|
178
|
+
|
|
179
|
+
- [X] T016 [P] [US2] Write `spec/ruby_reactor/dsl/step_contract_conflict_spec.rb` (red). Each case builds a reactor with `Class.new(RubyReactor::Reactor) { ... }` and `stub_const` so the name is stable. Cases: a mapping-only reactor (`argument :amount, input(:amount)`, with and without `transform:`) loads and the step's contract governs at run time (AS1); `argument :amount, input(:amount), :integer` raises `RubyReactor::Error::ValidationError` whose message includes the reactor name, `:charge`, `:amount`, and the step class name (AS2); a predicates-only `argument :amount, input(:amount), gt?: 0` raises the same; `validate_args do ... end` raises the same class of error (AS3); `argument :bogus, value(1)` raises naming `:bogus` (FR-018); `async_step :charge, ChargeStep` with a typed argument raises identically; a step class with **no** contract plus a typed `argument` loads and enforces the reactor's rule (AS4, unchanged behavior)
|
|
180
|
+
|
|
181
|
+
### Implementation for User Story 2
|
|
182
|
+
|
|
183
|
+
- [X] T017 [US2] In `lib/ruby_reactor/dsl/step_builder.rb#build`, before creating the `StepConfig`, when `@impl.respond_to?(:declares_inputs?) && @impl.declares_inputs?`: raise `Error::ValidationError` if `@arg_validations` is non-empty (name the first offending argument) or `@validate_args_input` is set, and if any `@arguments` key is not in `@impl.input_contract.declarations`. Message (FR-006): `"#{reactor_label} step :#{@name} declares rules on argument :#{arg}, but #{@impl} owns its input contract. Move the rule into #{@impl} (`input :#{arg}, ...`) and keep only the wiring here: `argument :#{arg}, <source>`."`. Unknown argument (FR-018): `"#{reactor_label} step :#{@name} wires argument :#{arg}, which #{@impl} does not declare. Declared inputs: #{names}."`. Add a private `reactor_label` (`@reactor&.name || @reactor.inspect`). T016 goes green
|
|
184
|
+
|
|
185
|
+
**Checkpoint**: Wiring and rules are split for class steps, and conflicts are caught when the reactor class body runs.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Phase 5: User Story 3 - Inline steps keep a single, coherent place for rules (Priority: P1)
|
|
190
|
+
|
|
191
|
+
**Goal**: `inputs do input ...; validate_inputs ... end` inside a `step` block. The lines are
|
|
192
|
+
identical to a step class's, and enforcement goes through the same `InputContract#enforce!`
|
|
193
|
+
(Finding 3).
|
|
194
|
+
|
|
195
|
+
**Independent Test**: The same declarations as an inline `inputs` block and as a step class, run
|
|
196
|
+
over the same conforming and violating inputs, give identical `success?`, value, and
|
|
197
|
+
`validation_errors`.
|
|
198
|
+
|
|
199
|
+
### Tests for User Story 3 ⚠️
|
|
200
|
+
|
|
201
|
+
- [X] T018 [P] [US3] Write `spec/ruby_reactor/dsl/inline_step_contract_spec.rb` (red). Cases: an inline `inputs` block with `input :amount, :integer, gteq?: 1`, `input :currency, :string, included_in?: %w[USD EUR]`, and a `validate_inputs` cross-field block fails with the same failure shape as the class form (AS1); the equivalence table (SC-005): for each input set in `[{amount: 5, currency: "USD"}, {amount: 0, currency: "USD"}, {amount: 5, currency: "JPY"}, {amount: 20_000, currency: "EUR"}]`, the inline reactor and a reactor using the same lines pasted into a step class give equal `success?`, value, and `validation_errors` (AS2); inside the `step` block but outside `inputs`, `input(:amount)` still returns a `Template::Input` (research Finding 4); defaults and `redact:` behave as in T007; conflicts raise `Error::ValidationError`: `inputs` inside `step :x, SomeStepClass`, `inputs` plus a typed `argument`, `inputs` plus `validate_args`, and an `argument` naming an undeclared input; `inputs do ... end` inside an `interrupt` block raises and points at `validate_payload`
|
|
202
|
+
|
|
203
|
+
### Implementation for User Story 3
|
|
204
|
+
|
|
205
|
+
- [X] T019 [US3] In `lib/ruby_reactor/dsl/step_builder.rb`: add `StepBuilder#inputs(&block)` that raises `Error::ValidationError` when `@impl` is set (`"`inputs` is for inline steps; declare `input` inside #{@impl}"`), otherwise builds `@inline_contract = Step::InputContract.new(owner: @name)` and `instance_eval`s the block on it. Pass `inline_contract:` into `StepConfig`, add the `StepConfig#inline_contract` reader, and change `StepConfig#input_contract` (T011) to return `inline_contract` when present. Extend T017's checks to inline contracts: typed `argument`/`validate_args` plus `inputs` raises, and an `argument` not declared in `inputs` raises (depends on T017)
|
|
206
|
+
- [X] T020 [US3] In `lib/ruby_reactor/executor/step_executor.rb#run_step_implementation`, in the `has_run_block?` branch, set `args_to_pass = step_config.inline_contract.enforce!(args_to_pass) if step_config.inline_contract` before `run_block.call`. T012(a) stamps the step name. T018 goes green (depends on T019)
|
|
207
|
+
- [X] T021 [US3] In `lib/ruby_reactor/dsl/interrupt_builder.rb`, override `inputs` to raise `Error::ValidationError`: `"interrupt :#{@name} does not take an `inputs` contract; validate the resume payload with `validate_payload`."` (Finding 11)
|
|
208
|
+
- [X] T022 [US3] Add `ContractInlineAsyncReactor` to `spec/support/reactors/step_contract_reactors.rb` (an `async_step :charge` with an `inputs` block matching `ContractChargeStep`, explicit `argument` lines, and the same `:confirm` reader). Add an example to `spec/ruby_reactor/step_contract_async_spec.rb` asserting the worker-side failure matches T014's, then confirm it fails (depends on T019)
|
|
209
|
+
- [X] T023 [US3] In `lib/ruby_reactor/step_worker.rb#execute_step_body`, in the `has_run_block?` branch, set `args = step_config.inline_contract.enforce!(args) if step_config.inline_contract`. T015's rescue produces the failure. T022 goes green (depends on T022)
|
|
210
|
+
|
|
211
|
+
**Checkpoint**: Inline and class steps share one vocabulary and one enforcement method, on every path.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Phase 6: User Story 4 - Missing wiring is caught when the reactor is defined (Priority: P2)
|
|
216
|
+
|
|
217
|
+
**Goal**: A declared input with no `argument` resolves from the same-named reactor input. A
|
|
218
|
+
required input satisfied by neither raises before any step runs (FR-008, FR-020, FR-021).
|
|
219
|
+
|
|
220
|
+
**Independent Test**: `SignupReactor` (inputs matching the step's names, no `argument` lines) runs.
|
|
221
|
+
`IncompleteReactor` raises `Error::ValidationError` naming `:profile`, `:email`, and both remedies,
|
|
222
|
+
and no step has run.
|
|
223
|
+
|
|
224
|
+
### Tests for User Story 4 ⚠️
|
|
225
|
+
|
|
226
|
+
- [X] T024 [P] [US4] Write `spec/ruby_reactor/dsl/step_contract_wiring_spec.rb` (red). Cases: US4 AS1–AS6 for a class step; the same inference for an inline step with an `inputs` block; `Reactor.validate_definition!` is public and callable without running; the error message contains the reactor name, step name, input name, `argument :<input>, ...`, and `input :<input>`; the error is raised from `.run` before any step runs (a preceding step pushes to a local array, which stays empty) and from `test_reactor(...)`; inferred entries in `steps[:x].arguments` have `origin: :inferred` and `source` a `Template::Input` for the same name; explicit entries have `origin: :explicit` and are never replaced (AS6); step results are never consulted: a prior step named `:email` does not satisfy a missing `:email` input, which still raises; calling `validate_definition!` twice does not change `arguments.size`; a `test_reactor(...).mock_step(...)` run still resolves by name (TestSubject subclasses the reactor, `test_subject.rb:501`); FR-019: an inline step without a contract or arguments still receives all reactor inputs
|
|
227
|
+
- [X] T025 [P] [US4] Add `ContractNameResolvedAsyncReactor` to `spec/support/reactors/step_contract_reactors.rb` (inputs `amount`, `currency`; `async_step :charge, ContractChargeStep` with **no** `argument` lines; the `:confirm` reader). Add an example to `spec/ruby_reactor/step_contract_async_spec.rb` asserting `amount: 5` succeeds in the live worker and `amount: 0` fails with `validation_errors[:amount]`. This fails until T028 (Finding 5)
|
|
228
|
+
|
|
229
|
+
### Implementation for User Story 4
|
|
230
|
+
|
|
231
|
+
- [X] T026 [US4] In `lib/ruby_reactor/dsl/step_builder.rb#argument`, store `origin: :explicit` in the `@arguments[name]` hash alongside `source:` and `transform:`
|
|
232
|
+
- [X] T027 [US4] Add a public `validate_definition!` to `Dsl::Reactor::ClassMethods` in `lib/ruby_reactor/dsl/reactor.rb`. It returns immediately when `@definition_validated`. Otherwise, for each `steps` value that responds to `input_contract` and has a non-nil one, for each declared name: skip if `arguments.key?(name)`; else if `inputs.key?(name)`, add `arguments[name] = { source: Template::Input.new(name), transform: nil, origin: :inferred }`; else, if the input is required, raise `Error::ValidationError` with `"#{name || inspect} step :#{step} requires input :#{input}, which is neither wired nor a reactor input. Wire it (`argument :#{input}, input(:x)` / `result(:step)`) or declare `input :#{input}` on the reactor."`. Then set `@definition_validated = true`. Add `# ponytail: a reactor reopened after its first run is not re-checked`
|
|
233
|
+
- [X] T028 [US4] Call `validate_definition!` from three places (Finding 5): the first line of `RubyReactor::Reactor#run` (`lib/ruby_reactor/reactor.rb:88`), as `self.class.validate_definition!`, so it raises before the context is saved; `Executor#initialize` (`lib/ruby_reactor/executor.rb:21`), as `reactor_class.validate_definition! if reactor_class.respond_to?(:validate_definition!)`, before the dependency graph is built, which covers resume, background, map, and compose workers; and `StepWorker#perform_unit` (`lib/ruby_reactor/step_worker.rb:57`), as `context.reactor_class.validate_definition!` before the step lookup. T024 and T025 go green (depends on T026, T027)
|
|
234
|
+
|
|
235
|
+
**Checkpoint**: Reactors whose input names match their steps need no `argument` lines, and incomplete wiring fails before step one.
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Phase 7: User Story 5 - Existing reactors keep working through the transition (Priority: P2)
|
|
240
|
+
|
|
241
|
+
**Goal**: Rules on `argument`/`validate_args` for steps without a contract behave exactly as
|
|
242
|
+
before and print one deprecation notice per declaration site (FR-010, FR-011).
|
|
243
|
+
|
|
244
|
+
**Independent Test**: An unchanged reactor with typed arguments on an inline step returns the same
|
|
245
|
+
results and errors, and prints one `DEPRECATION` line per site.
|
|
246
|
+
|
|
247
|
+
### Tests for User Story 5 ⚠️
|
|
248
|
+
|
|
249
|
+
- [X] T029 [P] [US5] Write `spec/ruby_reactor/step_contract_deprecation_spec.rb` (red). Cases: defining a reactor with `argument :amount, input(:amount), :integer, gteq?: 1` on an inline step prints to stderr (`output(/\[RubyReactor\] DEPRECATION:.*step :charge.*argument :amount.*input :amount/).to_stderr`) and names the defining file and line; `validate_args` prints a notice naming `inputs do ... validate_inputs`; defining the same reactor twice from one call site (two `Class.new` calls in a loop) prints once; a class step with no contract plus a typed argument also prints, and its message names the class; conforming and violating runs of each reactor give the same `success?`, value, `validation_errors`, and `step_name` as before (AS1, AS2); a mapping-only `argument` prints nothing
|
|
250
|
+
|
|
251
|
+
### Implementation for User Story 5
|
|
252
|
+
|
|
253
|
+
- [X] T030 [US5] In `lib/ruby_reactor/dsl/step_builder.rb`, record `caller_locations(1, 1).first` in `argument` (when a type or predicates are given) and in `validate_args`. In `build`, after T017/T019's conflict checks pass, `warn` once per `"#{path}:#{lineno}"`, tracked in a `Set` held in a `StepBuilder` class-level ivar: `"[RubyReactor] DEPRECATION: #{path}:#{lineno} #{reactor_label} step :#{@name} declares rules on `argument :#{arg}`. Declare them on the step instead (`input :#{arg}, ...` in #{@impl || 'an `inputs do ... end` block'}) and keep `argument :#{arg}, <source>` for wiring. Removal no earlier than the next MAJOR."`. The `validate_args` variant points at `validate_inputs` (Finding 12). T029 goes green
|
|
254
|
+
- [X] T031 [US5] Run `bundle exec rspec` and compare with `specs/002-step-input-contracts/baseline.txt`. Every example that passed at baseline must still pass without edits (SC-006). Deprecation output is expected. The only allowed behavior change is FR-023: an example that asserted `false → nil` gets fixed and listed in the CHANGELOG Bug Fixes entry (T038)
|
|
255
|
+
|
|
256
|
+
**Checkpoint**: Upgrading changes nothing but stderr notices and the falsey fix.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Phase 8: Polish & Cross-Cutting Concerns
|
|
261
|
+
|
|
262
|
+
**Purpose**: Docs, changelog, and the constitution-required demo and acceptance run
|
|
263
|
+
|
|
264
|
+
**Constitution Principle VI — Demo-App Proof of Feature (required for any public API change):**
|
|
265
|
+
|
|
266
|
+
- [X] T032 [P] Create `demo_app/app/reactors/validated_user_step.rb` (`ValidatedUserStep`, following the `ReserveInventory` file precedent) with the exact contract from quickstart Scenario 1 (`name` min_size 2, `email`, `age` gteq 18, optional `bio` with default and max_size 100, `marketing_opt_in, :bool`). `run` logs via `Rails.logger` and returns `Success(args.merge(created_at: Time.current.iso8601))`
|
|
267
|
+
- [X] T033 [P] Create `demo_app/app/reactors/validated_signup_reactor.rb` (`ValidatedSignupReactor`: inputs `name`, `email`, `age`, `marketing_opt_in`; `step :profile, ValidatedUserStep` with no `argument` lines; `returns :profile`) and `demo_app/app/reactors/validated_signup_async_reactor.rb` (`ValidatedSignupAsyncReactor`: same inputs; `async_step :profile, ValidatedUserStep`; `step :welcome` reading `result(:profile)` and returning it). Neither reactor declares a rule (SC-001), and both reuse one step class (SC-002) (depends on T032)
|
|
268
|
+
- [X] T034 Register `demo:validated_signup` in `demo_app/lib/tasks/demo_reactors.rake` with a `desc` and `[:environment, :flush_redis]`, following `demo:signal_demo`'s `run_*` helper style. Print ✅/❌ lines for: a passing run (bio defaulted); `name: "A", age: 17` (print `validation_errors` and `step_name`); `marketing_opt_in: false` passing with `false` shown; the async variant failing inside the worker with the same `validation_errors`; and `ValidatedUserStep.run({ age: 17 }, nil)` rescued and printed (depends on T033)
|
|
269
|
+
- [X] T035 Write `demo_app/spec/reactors/validated_signup_reactor_spec.rb` (`type: :reactor`) using only `test_reactor`, `be_success`, `be_failure`, `have_validation_error`, `step_result`: the passing run and bio default; `have_validation_error(:name)` and `have_validation_error(:age)`; `marketing_opt_in: false` succeeds and `step_result(:profile)[:marketing_opt_in] == false`; the async variant (follow `demo_app/spec/reactors/async_step_demo_reactor_spec.rb`) fails with `have_validation_error(:age)`. If any assertion can't be expressed with the shipped surface, add the matcher to `lib/ruby_reactor/rspec/matchers.rb` in this change, never hand-rolled (depends on T033)
|
|
270
|
+
- [X] T036 Check `docker-compose.yml` (`demo-app`, `demo-sidekiq`, demo Redis). No new service or env var should be needed. If the async variant needs one, add it here (Constitution VI.4)
|
|
271
|
+
|
|
272
|
+
**Docs and release notes:**
|
|
273
|
+
|
|
274
|
+
- [X] T037 [P] Update `README.md`: in "Defining Steps" (line ~224), make the class-step example declare `input` lines. Rewrite "Step Argument & Output Validation" (line ~1021) to show the step-class contract first, the inline `inputs do` equivalent, the name-based resolution rule, the conflict and unknown-argument errors, the explicit "no per-reactor overrides: write two steps or relax the contract" note (spec Edge Cases), presence semantics (contract §9), and a migration block from `argument :x, src, :type, **rules` / `validate_args` to `input`/`validate_inputs`. Note that `validate_definition!` can run in an initializer or CI (FR-016)
|
|
275
|
+
- [X] T038 [P] Add to `CHANGELOG.md` under `## Unreleased`: **Features**, covering step input contracts (`input`/`validate_inputs` on step classes, `inputs do` for inline steps, name-based resolution, `validate_definition!`) plus the migration note and the deprecation of rules on `argument`/`validate_args` (removal no earlier than the next MAJOR); **Bug Fixes**, noting that a supplied `false` reactor input or step result no longer resolves to `nil` (`Context#get_input`, `#get_result`, `Template::Result#fetch`)
|
|
276
|
+
|
|
277
|
+
**Verification:**
|
|
278
|
+
|
|
279
|
+
- [X] T039 Run `bundle exec rubocop` with no `--disable-pending-cops`. The offense count must not exceed the baseline in `specs/002-step-input-contracts/baseline.txt`, and there are no new offenses in touched files
|
|
280
|
+
- [X] T040 Run the docker acceptance: `docker compose up -d demo-redis demo-sidekiq`, `docker compose run --rm demo-app bin/rails demo:validated_signup` (every printed line matches T034's expectations), and `docker compose run --rm demo-app bundle exec rspec spec/reactors/validated_signup_reactor_spec.rb` (SC-008)
|
|
281
|
+
- [X] T041 Walk quickstart.md Scenarios 1–6 and its acceptance checklist SC-001…SC-011, ticking each against the spec or demo output that proves it. Run the full `bundle exec rspec` one final time and compare it with the baseline
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Dependencies & Execution Order
|
|
286
|
+
|
|
287
|
+
### Phase Dependencies
|
|
288
|
+
|
|
289
|
+
- **Setup (Phase 1)**: No dependencies
|
|
290
|
+
- **Foundational (Phase 2)**: Depends on T001. Blocks all stories (US1 AS6 and defaults need T004; `InputContract` needs T005)
|
|
291
|
+
- **US1 (Phase 3)**: Depends on Phase 2
|
|
292
|
+
- **US2 (Phase 4)**: Depends on US1 (`declares_inputs?`, `input_contract` on step classes)
|
|
293
|
+
- **US3 (Phase 5)**: Depends on US1 (`InputContract`, T012 stamping, T015 worker rescue) and US2 (T019 extends T017's checks)
|
|
294
|
+
- **US4 (Phase 6)**: Depends on US1. Its inline-step case also needs US3's T019
|
|
295
|
+
- **US5 (Phase 7)**: Depends on US2 and US3 (the notice is emitted only after their conflict checks pass)
|
|
296
|
+
- **Polish (Phase 8)**: T032–T036 need US1 + US4 (the demo reactor has no `argument` lines). T037–T041 need all stories
|
|
297
|
+
|
|
298
|
+
### User Story Dependencies
|
|
299
|
+
|
|
300
|
+
```text
|
|
301
|
+
Phase 2 ──► US1 ──► US2 ──► US3 ──► US5
|
|
302
|
+
│ ▲
|
|
303
|
+
└──────► US4 ─────┘ (inline case only)
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Within Each User Story
|
|
307
|
+
|
|
308
|
+
- Spec task first, confirmed red, then implementation, then green
|
|
309
|
+
- `step.rb` tasks (T009 → T010) are sequential: same file
|
|
310
|
+
- `step_builder.rb` tasks (T011, T017, T019, T026, T030) are sequential across phases: same file
|
|
311
|
+
- `step_contract_reactors.rb` / `step_contract_async_spec.rb` (T013, T014, T022, T025) are sequential: same files
|
|
312
|
+
|
|
313
|
+
### Parallel Opportunities
|
|
314
|
+
|
|
315
|
+
- Phase 2: T002, T003, T005 together (then T004 after T003)
|
|
316
|
+
- US1: T006 ∥ T007 (both red before any lib change)
|
|
317
|
+
- US2 ∥ US4 test writing: T016 ∥ T024 ∥ T025 once US1 is green
|
|
318
|
+
- US4 T024 ∥ US3 T018 (different spec files)
|
|
319
|
+
- US5: T029 can be written any time after US1
|
|
320
|
+
- Polish: T032 ∥ T037 ∥ T038, then T033 → T034/T035
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Parallel Example: User Story 1
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
# Both red specs at once:
|
|
328
|
+
Task: "T006 Write spec/ruby_reactor/dsl/step_input_contract_spec.rb (declaration, introspection, inheritance, subclass wrapping)"
|
|
329
|
+
Task: "T007 Write spec/ruby_reactor/step_contract_enforcement_spec.rb (sync paths, attribution, redaction, rollback, direct call)"
|
|
330
|
+
|
|
331
|
+
# After US1 is green, the next stories' specs at once:
|
|
332
|
+
Task: "T016 Write spec/ruby_reactor/dsl/step_contract_conflict_spec.rb"
|
|
333
|
+
Task: "T018 Write spec/ruby_reactor/dsl/inline_step_contract_spec.rb"
|
|
334
|
+
Task: "T024 Write spec/ruby_reactor/dsl/step_contract_wiring_spec.rb"
|
|
335
|
+
Task: "T029 Write spec/ruby_reactor/step_contract_deprecation_spec.rb"
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## Implementation Strategy
|
|
341
|
+
|
|
342
|
+
### MVP First (User Story 1 Only)
|
|
343
|
+
|
|
344
|
+
1. T001 baseline → Phase 2 (falsey fix + shared dispatch)
|
|
345
|
+
2. Phase 3: step-class contracts enforced on every path, including the live worker
|
|
346
|
+
3. **STOP and VALIDATE**: T006, T007, T014 green. Full suite at baseline
|
|
347
|
+
4. Shippable on its own: authors can move rules into step classes, and reactors using them wire
|
|
348
|
+
inputs with explicit `argument` lines
|
|
349
|
+
|
|
350
|
+
### Incremental Delivery
|
|
351
|
+
|
|
352
|
+
1. + US2 → reactor-side conflicts rejected (the ambiguity the feature exists to remove)
|
|
353
|
+
2. + US3 → inline steps get the same vocabulary. The equivalence spec pins both forms
|
|
354
|
+
3. + US4 → name-based wiring and fail-before-step-one satisfiability
|
|
355
|
+
4. + US5 → deprecation notices. Migration is documented
|
|
356
|
+
5. Polish → demo app, README, CHANGELOG, docker acceptance
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## Notes
|
|
361
|
+
|
|
362
|
+
- [P] = different files, no dependency on an incomplete task
|
|
363
|
+
- Run `docker compose up -d redis-test` before any spec. `spec_helper` aborts without Redis
|
|
364
|
+
- The deviations from research.md, D4 (no `args_validator` for inline contracts) and D3
|
|
365
|
+
(overwrite, not `||=`), are explained in Findings 3 and 6. Reflect them in plan.md's Complexity
|
|
366
|
+
Tracking row if a reviewer asks: there is now one enforcement method, not two mechanisms
|
|
367
|
+
- Commit after each green checkpoint
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Specification Quality Checklist: Inheritable Step Class
|
|
2
|
+
|
|
3
|
+
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
|
4
|
+
**Created**: 2026-09-11
|
|
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
|
+
- Feature is a public-API refactor of a Ruby library, so the spec necessarily names the
|
|
35
|
+
public authoring surface (`RubyReactor::Step`, `run`, `fail!`, `call`). These are the
|
|
36
|
+
product, not implementation details; internal mechanics (prepend, singleton wrapping)
|
|
37
|
+
appear only in the Input quote and in FR-011 as things that must NOT exist.
|
|
38
|
+
- Key scope decision (single authoring style, mixin removed) resolved via the decision rule
|
|
39
|
+
the description supplied; recorded under Assumptions rather than left as a clarification.
|
|
40
|
+
- Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan`
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Public API Contract: `RubyReactor::Step`
|
|
2
|
+
|
|
3
|
+
RubyReactor is a library (Constitution Principle I) — its "contract" is the public Ruby
|
|
4
|
+
API surface it exposes to gem consumers, not a network endpoint. This document is that
|
|
5
|
+
contract for the step-authoring surface this feature replaces.
|
|
6
|
+
|
|
7
|
+
## Authoring contract
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class MyStep < RubyReactor::Step
|
|
11
|
+
input :email, :string, format?: EMAIL_REGEX
|
|
12
|
+
input :user_id, :integer
|
|
13
|
+
|
|
14
|
+
def run
|
|
15
|
+
# `inputs` and `context` expose the validated arguments and the workflow context.
|
|
16
|
+
# Return a result wrapper, or end early with a signal.
|
|
17
|
+
fail!("nop") unless something
|
|
18
|
+
Success(value: inputs[:user_id])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def undo
|
|
22
|
+
# `result` holds this step's own stored result value.
|
|
23
|
+
Skipped() # default if omitted
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def compensate
|
|
27
|
+
# `reason` holds the failure that triggered rollback.
|
|
28
|
+
Skipped() # default if omitted
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Instance readers: `inputs`, `context`, `result` (undo only), `reason` (compensate only).
|
|
34
|
+
All set once in the constructor; see data-model.md. `inputs` holds the same values in every
|
|
35
|
+
action: the arguments with the contract's defaults applied.
|
|
36
|
+
|
|
37
|
+
- **MUST** subclass `RubyReactor::Step`. `include RubyReactor::Step` on a plain class is
|
|
38
|
+
no longer supported (FR-012) — it is not a compatibility path, it is simply gone.
|
|
39
|
+
- **MUST** declare inputs, if any, with `input`/`validate_inputs` at the class level,
|
|
40
|
+
identically to the input-contracts feature (FR-002). Declaring none means no validation
|
|
41
|
+
runs, and `run` receives the raw resolved arguments (unchanged behavior).
|
|
42
|
+
- **MUST** implement `run` as an instance method with no required parameters. Omitting it
|
|
43
|
+
raises `NotImplementedError` naming the subclass when the step is invoked (FR-010).
|
|
44
|
+
- **MAY** implement `undo`/`compensate` as instance methods with no required parameters;
|
|
45
|
+
omitting either defaults to `Skipped()` (FR-009).
|
|
46
|
+
- Inside `run`/`undo`/`compensate`, `Success`, `Failure`, `Halt`, `Skipped`, and the
|
|
47
|
+
signal helpers `success!`/`skip!`/`fail!`/`halt!` are available as bare instance calls
|
|
48
|
+
(FR-008). `Failure` takes exactly the arguments `RubyReactor.Failure` takes.
|
|
49
|
+
|
|
50
|
+
## Invocation contract (what every caller in the library gets)
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
RubyReactor::Step.run(arguments, context) # => Success/Failure/Halt/Skipped
|
|
54
|
+
RubyReactor::Step.call(arguments, context) # alias of .run
|
|
55
|
+
RubyReactor::Step.undo(result, arguments, context) # => Success/Failure/Halt/Skipped
|
|
56
|
+
RubyReactor::Step.compensate(reason, arguments, context) # => Success/Failure/Halt/Skipped
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- **MUST** be pure from the caller's perspective: given the same `arguments`/`context`
|
|
60
|
+
(and `result`/`reason` for undo/compensate), behavior does not depend on whether `.run`
|
|
61
|
+
was previously called on the same class in the same process (FR-009, D2).
|
|
62
|
+
- **MUST NOT** raise `StepSignals`' internal throw to the caller — every signal is
|
|
63
|
+
translated into the matching result wrapper before `.run`/`.undo`/`.compensate` return
|
|
64
|
+
(FR-007).
|
|
65
|
+
- **MUST** raise the existing `Error::InputValidationError` (with `step_name` and
|
|
66
|
+
`step_arguments` set) when the declared contract rejects `arguments`, before the
|
|
67
|
+
instance's `run` method executes (FR-006) — same error class and same attributes as
|
|
68
|
+
today, only relocated internally (D-series decisions in research.md).
|
|
69
|
+
- That error's `retryable?` **MUST** be `false` (FR-017, research.md D10), so any `Failure`
|
|
70
|
+
built from it — on the synchronous path, the async worker, or surfaced through a
|
|
71
|
+
composed reactor — is non-retryable without its caller having to say so.
|
|
72
|
+
- `.undo`/`.compensate` **MUST NOT** enforce the contract, so rollback cannot fail on the
|
|
73
|
+
inputs that caused the failure. They **MUST** still apply its defaults, so `inputs` matches
|
|
74
|
+
what `.run` saw.
|
|
75
|
+
- An ordinary (non-signal) exception raised inside `run`/`undo`/`compensate` **MUST**
|
|
76
|
+
propagate unchanged — this contract governs signals and validation only.
|
|
77
|
+
|
|
78
|
+
## Compatibility note
|
|
79
|
+
|
|
80
|
+
This is a **breaking change** to the public API (Constitution Principle V — SemVer). No
|
|
81
|
+
prior form is preserved. Any code outside this repository using
|
|
82
|
+
`include RubyReactor::Step` with `def self.run` must convert to
|
|
83
|
+
`class Foo < RubyReactor::Step` with an instance `def run` to keep working. `CHANGELOG.md`
|
|
84
|
+
records this under a breaking-change heading with a before/after example (spec FR-015,
|
|
85
|
+
User Story 5); release-please's `bump-minor-pre-major` takes 0.7.0 to 0.8.0.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Phase 1 Data Model: Inheritable Step Class
|
|
2
|
+
|
|
3
|
+
This feature has no persisted schema or database entities — it reshapes an in-memory
|
|
4
|
+
authoring/execution abstraction. "Entities" here are Ruby-level concepts, matching the
|
|
5
|
+
Key Entities section of spec.md, made concrete for implementation.
|
|
6
|
+
|
|
7
|
+
## `RubyReactor::Step` (the base class)
|
|
8
|
+
|
|
9
|
+
The single inheritable parent every class-based step derives from. Replaces the current
|
|
10
|
+
`module RubyReactor::Step` (mixin) — same constant name, different kind of object (a
|
|
11
|
+
`Class`, not a `Module`), per spec FR-001 and FR-012.
|
|
12
|
+
|
|
13
|
+
**Class-level surface** (the lifecycle, callable by every execution path per D1):
|
|
14
|
+
|
|
15
|
+
| Method | Signature | Behavior |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `self.run` | `(arguments, context)` | Enforce input contract if declared (skip if `declares_inputs?` is false; a violation *raises* `InputValidationError` with `step_name` set, outside any catch) → `new(validated, context)` (D2/D3) → `catch(StepSignals::TAG) { instance.run }` → return the result wrapper or the caught signal's wrapper |
|
|
18
|
+
| `self.call` | `(arguments, context)` | Alias of `self.run` (D1) |
|
|
19
|
+
| `self.undo` | `(result, arguments, context)` | `new(arguments, context, result: result)` — fresh instance (D2/D3) → `catch(StepSignals::TAG) { instance.undo }` |
|
|
20
|
+
| `self.compensate` | `(reason, arguments, context)` | `new(arguments, context, reason: reason)` — fresh instance (D2/D3) → `catch(StepSignals::TAG) { instance.compensate }` |
|
|
21
|
+
|
|
22
|
+
The class-level entry points own the `catch` — that is what makes `StepWorker` (which has
|
|
23
|
+
no catch of its own today, see research.md D4) behave identically to the executor for
|
|
24
|
+
class steps without touching the worker.
|
|
25
|
+
|
|
26
|
+
**Class-level DSL surface** (unchanged from today, ported verbatim from the current
|
|
27
|
+
`ClassMethods` module per D5/D6 — ***not*** part of this feature's design decisions, just
|
|
28
|
+
carried forward):
|
|
29
|
+
|
|
30
|
+
- `input(...)`, `validate_inputs(...)` — delegate to `own_input_contract` (unchanged
|
|
31
|
+
`InputContract`, `lib/ruby_reactor/step/input_contract.rb`, untouched by this feature).
|
|
32
|
+
- `input_contract`, `declared_inputs`, `required_input_names`, `declares_inputs?` —
|
|
33
|
+
unchanged, including the parent-first `merge` on `inherited` (spec FR-002, User Story 4).
|
|
34
|
+
- `inherited(subclass)` — **deleted.** Today it exists solely to re-prepend
|
|
35
|
+
`InputEnforcement` onto each subclass's singleton class; contract memoization
|
|
36
|
+
(`@input_contract`, `@own_input_contract`) is already per-class instance variables and
|
|
37
|
+
needs no reset. With the prepend gone, the hook has nothing left to do.
|
|
38
|
+
|
|
39
|
+
**Result-wrapper / signal helpers available inside instance methods** (spec FR-008):
|
|
40
|
+
`Success`, `Failure`, `Halt`, `Skipped` and the `StepSignals` throw helpers (`success!`,
|
|
41
|
+
`skip!`, `fail!`, `halt!`) must be callable as bare instance methods inside `run`/`undo`/
|
|
42
|
+
`compensate` bodies — the base class includes `RubyReactor::StepSignals` at the instance
|
|
43
|
+
level (not only, as today, at the class/singleton level) and defines the same four
|
|
44
|
+
`Success`/`Failure`/`Halt`/`Skipped` wrapper methods as instance methods, delegating to
|
|
45
|
+
the module-level `RubyReactor.Success` etc.
|
|
46
|
+
|
|
47
|
+
## `Step` instance (short-lived, per lifecycle action)
|
|
48
|
+
|
|
49
|
+
Built fresh for every `run`/`undo`/`compensate` call (D2) — never reused, never crosses a
|
|
50
|
+
process boundary as an object (only its inputs do, via the normal `arguments`/`context`/
|
|
51
|
+
`result`/`reason` that already flow through Redis-backed context serialization
|
|
52
|
+
unaffected by this feature).
|
|
53
|
+
|
|
54
|
+
Constructor: `initialize(inputs, context, result: nil, reason: nil)` (research.md D3).
|
|
55
|
+
|
|
56
|
+
| Reader | Holds | Set by | Read by |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| `inputs` | the argument hash with the contract's defaults applied (raw if no contract), identical in every action | `self.run` after enforcement; `self.undo`/`self.compensate` apply defaults only, never enforce | `run`, `undo`, `compensate` |
|
|
59
|
+
| `context` | the workflow `RubyReactor::Context` | constructor | `run`, `undo`, `compensate` |
|
|
60
|
+
| `result` | the step's own stored result value | `self.undo`'s `result` parameter; nil otherwise | `undo` |
|
|
61
|
+
| `reason` | the failure that triggered rollback | `self.compensate`'s `reason` parameter; nil otherwise | `compensate` |
|
|
62
|
+
|
|
63
|
+
All four are plain `attr_reader`s set once in the constructor. No other mutable state. An
|
|
64
|
+
instance built for `run` is discarded after `self.run` returns; `undo`/`compensate` never
|
|
65
|
+
read anything a prior `run` instance touched (this is the D2 guarantee that closes the
|
|
66
|
+
Complexity Tracking risk in plan.md).
|
|
67
|
+
|
|
68
|
+
Namespace: `RubyReactor::Step` is now a class, so the four constants under it
|
|
69
|
+
(`Step::InputContract`, `Step::ComposeStep`, `Step::MapStep`, `Step::AsyncReactorStep`)
|
|
70
|
+
live inside a class rather than a module — every file that opens the namespace must say
|
|
71
|
+
`class Step` (research.md D9).
|
|
72
|
+
|
|
73
|
+
## `InputContract` (existing, unchanged)
|
|
74
|
+
|
|
75
|
+
No structural change. Still owns `declarations`, `cross_field_validators`, `#enforce!`,
|
|
76
|
+
`#merge` (parent-first), `#empty?`/`declares_inputs?`. This feature relocates *who calls*
|
|
77
|
+
`#enforce!` (from a `singleton_class.prepend`-installed `InputEnforcement#run` to
|
|
78
|
+
`self.run`'s own body) without touching the contract's own behavior — every acceptance
|
|
79
|
+
scenario in the prior feature (002-step-input-contracts) that exercises `InputContract`
|
|
80
|
+
directly is unaffected.
|
|
81
|
+
|
|
82
|
+
## Result wrapper / Signal (existing, mostly unchanged)
|
|
83
|
+
|
|
84
|
+
`Success`, `Failure`, `Halt`, `Skipped` (module-level constructors in `RubyReactor`) and
|
|
85
|
+
`StepSignals::TAG` throw/catch mechanism are unchanged in shape and semantics — this
|
|
86
|
+
feature only relocates *where* the `catch(StepSignals::TAG)` for a class-based step's own
|
|
87
|
+
body lives (D4), not what a signal means or how it is thrown.
|
|
88
|
+
|
|
89
|
+
One property is fixed, not just relocated: a `Failure` built from a validation error is
|
|
90
|
+
always non-retryable (`retryable?` returns `false`), regardless of which execution path
|
|
91
|
+
produced it. This is enforced once, on `Error::InputValidationError#retryable?` itself
|
|
92
|
+
(research.md D10) — `RubyReactor::Failure` already asks the wrapped error object, so no
|
|
93
|
+
call site (synchronous executor, async worker, compose) has to remember to say so.
|
|
94
|
+
|
|
95
|
+
## Relationships
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
RubyReactor::Step (class)
|
|
99
|
+
│ subclassed by
|
|
100
|
+
▼
|
|
101
|
+
Concrete step class (e.g. MyStep, ComposeStep, MapStep, AsyncReactorStep)
|
|
102
|
+
│ declares (class-level DSL) │ invoked by (class-level entry points)
|
|
103
|
+
▼ ▼
|
|
104
|
+
InputContract (declarations, merge) Executor::StepExecutor / CompensationManager /
|
|
105
|
+
RSpec::TestSubject (unchanged call sites)
|
|
106
|
+
│ enforced by self.run, producing
|
|
107
|
+
▼
|
|
108
|
+
validated argument hash ──────────────► Step instance (per-action, D2)
|
|
109
|
+
│ run/undo/compensate body executes,
|
|
110
|
+
│ may throw a Signal
|
|
111
|
+
▼
|
|
112
|
+
StepSignals::TAG catch (D4, at self.run/undo/compensate)
|
|
113
|
+
│
|
|
114
|
+
▼
|
|
115
|
+
Result wrapper (Success/Failure/Halt/Skipped)
|
|
116
|
+
```
|