ruby_reactor 0.8.3 → 0.8.5

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/CHANGELOG.md +208 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +140 -102
  7. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  8. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +10 -7
  9. data/lib/ruby_reactor/dsl/compose_builder.rb +10 -11
  10. data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
  11. data/lib/ruby_reactor/dsl/map_builder.rb +0 -2
  12. data/lib/ruby_reactor/dsl/reactor.rb +8 -11
  13. data/lib/ruby_reactor/dsl/retryable.rb +45 -0
  14. data/lib/ruby_reactor/dsl/step_builder.rb +131 -33
  15. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  16. data/lib/ruby_reactor/error/rescuable.rb +28 -0
  17. data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
  18. data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
  19. data/lib/ruby_reactor/executor/result_handler.rb +21 -18
  20. data/lib/ruby_reactor/executor/retry_manager.rb +11 -12
  21. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  22. data/lib/ruby_reactor/executor/step_executor.rb +59 -49
  23. data/lib/ruby_reactor/executor.rb +38 -4
  24. data/lib/ruby_reactor/map/collector.rb +21 -11
  25. data/lib/ruby_reactor/map/dispatcher.rb +29 -3
  26. data/lib/ruby_reactor/map/element_executor.rb +9 -3
  27. data/lib/ruby_reactor/map/helpers.rb +32 -2
  28. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  29. data/lib/ruby_reactor/reactor.rb +24 -0
  30. data/lib/ruby_reactor/rspec/matchers.rb +19 -3
  31. data/lib/ruby_reactor/rspec/test_subject.rb +3 -5
  32. data/lib/ruby_reactor/step/async_reactor_step.rb +6 -2
  33. data/lib/ruby_reactor/step/compose_step.rb +14 -4
  34. data/lib/ruby_reactor/step/input_contract.rb +5 -0
  35. data/lib/ruby_reactor/step/inputs.rb +57 -0
  36. data/lib/ruby_reactor/step/map_step.rb +140 -25
  37. data/lib/ruby_reactor/step.rb +20 -7
  38. data/lib/ruby_reactor/step_worker.rb +46 -22
  39. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  40. data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
  41. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  42. data/lib/ruby_reactor/version.rb +1 -1
  43. data/lib/ruby_reactor/web/api.rb +1 -1
  44. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
  45. data/lib/ruby_reactor/web/public/index.html +1 -1
  46. data/lib/ruby_reactor/worker.rb +3 -1
  47. data/lib/ruby_reactor.rb +20 -7
  48. data/specs/006-step-retry-declarations/checklists/requirements.md +41 -0
  49. data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
  50. data/specs/006-step-retry-declarations/data-model.md +58 -0
  51. data/specs/006-step-retry-declarations/plan.md +187 -0
  52. data/specs/006-step-retry-declarations/quickstart.md +80 -0
  53. data/specs/006-step-retry-declarations/research.md +194 -0
  54. data/specs/006-step-retry-declarations/spec.md +453 -0
  55. data/specs/006-step-retry-declarations/tasks.md +382 -0
  56. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  57. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  58. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  59. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  60. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  61. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  62. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  63. data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
  64. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  65. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  66. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  67. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
  68. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  69. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  70. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  71. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  72. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  73. data/specs/007-execution-flow-analysis/plan.md +127 -0
  74. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  75. data/specs/007-execution-flow-analysis/research.md +202 -0
  76. data/specs/007-execution-flow-analysis/spec.md +270 -0
  77. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  78. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  79. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  80. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  81. data/specs/008-rollback-reliability/data-model.md +139 -0
  82. data/specs/008-rollback-reliability/plan.md +233 -0
  83. data/specs/008-rollback-reliability/quickstart.md +105 -0
  84. data/specs/008-rollback-reliability/research.md +653 -0
  85. data/specs/008-rollback-reliability/spec.md +561 -0
  86. data/specs/008-rollback-reliability/tasks.md +1110 -0
  87. data/specs/future_improvements.md +48 -0
  88. data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
  89. metadata +47 -2
@@ -0,0 +1,194 @@
1
+ # Research: Step-Scoped Retry Declarations
2
+
3
+ Every decision below was checked against the code on `retry_confs_in_steps` (base `900d6522`).
4
+ Nothing in Technical Context was left open.
5
+
6
+ ## R1 — How `retry_defaults` works today
7
+
8
+ **Finding**:
9
+
10
+ - `Dsl::Reactor.included` sets `@retry_defaults = {max_attempts: 3, …}` on
11
+ `RubyReactor::Reactor` **only** (`lib/ruby_reactor/dsl/reactor.rb:14`). Nothing copies it to
12
+ subclasses.
13
+ - The reader `retry_defaults` lazily returns `{max_attempts: 1, …}` for any subclass that never
14
+ called it (`dsl/reactor.rb:58-68`).
15
+ - `StepBuilder#build`, `ComposeBuilder#build` and `AsyncReactorBuilder#build` **snapshot**
16
+ `@reactor.retry_defaults` into `retry_config` when their own block declared none
17
+ (`step_builder.rb:163`, `compose_builder.rb:74`, `async_reactor_builder.rb:52`). So a
18
+ `retry_defaults` line only affects steps declared *after* it. This is the "unpredictable"
19
+ behavior the user called out.
20
+ - `RetryManager#calculate_backoff_delay` falls back to `reactor_class.retry_defaults` for
21
+ `backoff`/`base_delay` (`executor/retry_manager.rb:29-30`).
22
+ - `RSpec::TestSubject` copies `@retry_defaults` into its three generated execution subclasses
23
+ (`rspec/test_subject.rb:559, 651, 739`).
24
+
25
+ **Consequence**: a reactor that never called `retry_defaults` already runs undeclared steps
26
+ exactly once. Removing the feature changes behavior only for reactors that call it (spec US1
27
+ scenario 3).
28
+
29
+ **Callers to migrate**: `spec/async_retry_dsl_spec.rb:23-33`, `spec/ruby_reactor_spec.rb:14, 221`,
30
+ `spec/support/order_processing_reactor.rb:28`, `demo_app/spec/support/order_processing_reactor.rb:28`,
31
+ plus docs (see R10).
32
+
33
+ ## R2 — How to remove `retry_defaults`
34
+
35
+ **Decision**: replace the method with a stub that raises `Error::DeprecatedDslError` when the
36
+ class is defined, and delete every read of it (the builder fallbacks, the `RetryManager`
37
+ fallback, the `TestSubject` copies, and the `included` ivar).
38
+
39
+ **Rationale**: this is exactly how the project removed `async true` on reactors
40
+ (`dsl/reactor.rb:48-56`) and `async` in step/compose blocks (`step_builder.rb:119-131`). A call
41
+ fails loudly at load time with a migration message instead of being silently ignored. The
42
+ error names the reactor (`name` or `"anonymous reactor"`) and says: declare `retries` on each
43
+ step class or step block that needs it.
44
+
45
+ **Alternatives considered**:
46
+
47
+ - *Deprecation warning, keep working for one release*: rejected. The user asked to remove it
48
+ altogether, and keeping it alive also keeps the ordering bug from R1.
49
+ - *Delete the method outright (`NoMethodError`)*: rejected. It gives no migration hint and
50
+ breaks the project's established removal pattern.
51
+
52
+ **Delivery**: this is Phase A of the plan and ships first, as its own commit, with the full
53
+ suite green (spec FR-017).
54
+
55
+ ## R3 — Where `retries` lives for a step class
56
+
57
+ **Decision**: a new module `RubyReactor::Dsl::Retryable`, modeled on `Dsl::Lockable::ClassMethods`,
58
+ containing:
59
+
60
+ - `retries(max_attempts: 3, backoff: :exponential, base_delay: 1)`: validates, then stores
61
+ `@retry_config`;
62
+ - `retry_config`: the reader, `nil` when nothing was declared;
63
+ - `inherited(subclass)`: copies `@retry_config` to the subclass, exactly like
64
+ `Lockable#inherited`.
65
+
66
+ `RubyReactor::Step` **extends** it (class-level DSL). `StepBuilder`, `ComposeBuilder` and
67
+ `AsyncReactorBuilder` **include** it, which replaces their three identical `retries` methods.
68
+ The same is already done with `Lockable::ClassMethods` in `StepBuilder` (`step_builder.rb:8`).
69
+
70
+ **Rationale**: the user asked us to emulate the locks design. One module gives both forms the
71
+ same vocabulary, the same defaults and the same validation (spec FR-001/FR-002/FR-004), and it
72
+ removes duplication instead of adding it. Inheritance by copying in `inherited` matches locks,
73
+ so a subclass redeclaring its policy leaves its parent and siblings untouched (FR-010).
74
+
75
+ **Alternatives considered**:
76
+
77
+ - *Walk `superclass` in the reader (like `input_contract`)*: works too, but it would be a
78
+ second inheritance mechanism next to Lockable's. Rejected for consistency.
79
+ - *A `RetryPolicy` value object*: rejected. There is only one consumer shape (a 3-key hash
80
+ read by `RetryManager`/`StepWorker`), so an object would be an abstraction with no second
81
+ use case (Constitution V).
82
+
83
+ ## R4 — Validation of retry values
84
+
85
+ **Decision**: `Retryable#retries` raises `ArgumentError` when the declaration is made if:
86
+
87
+ - `max_attempts` is not an `Integer` `>= 1`;
88
+ - `backoff` is not one of `%i[exponential linear fixed]`;
89
+ - `base_delay` is not a `Numeric` `>= 0`.
90
+
91
+ The message names the owner (`name`: the step symbol in a builder, the class name on a step
92
+ class), the option and the bad value.
93
+
94
+ **Rationale**: `ArgumentError` at declaration time is what `with_period`, `with_rate_limit`
95
+ and `validate_rollback_wait!` already do (`dsl/lockable.rb`). An unknown backoff strategy
96
+ fails today only at the first retry (`RetryContext.calculate_backoff_delay` raises
97
+ `ArgumentError`, `retry_context.rb:94`), which can be in production, days later.
98
+ `ActiveSupport::Duration` (`5.seconds`) passes the `Numeric` check, because
99
+ `Duration#is_a?` delegates to its value, so the documented Rails idiom keeps working.
100
+
101
+ **Behavior change**: `max_attempts: 0`, which today means "no retry", is now refused and must
102
+ be written `max_attempts: 1` (spec Assumptions). It goes in the migration note.
103
+
104
+ ## R5 — Effective policy and precedence on `StepConfig`
105
+
106
+ **Decision**: `StepConfig` stores the builder's own declaration (`nil` when none) and resolves
107
+ it lazily, the same way `lock_config` does (`step_builder.rb:286-304`):
108
+
109
+ ```text
110
+ retry_config = own declaration || impl.retry_config (if impl responds) || NO_RETRIES
111
+ retry_source = :step_block | :step_class | :none
112
+ ```
113
+
114
+ `NO_RETRIES = { max_attempts: 1, backoff: :exponential, base_delay: 1 }.freeze`.
115
+ `retryable?`, `RetryManager`, `StepWorker` and `StepCoordination#retry_pending?` keep reading
116
+ `retry_config` unchanged. Every hash now carries all three keys, so the reactor fallbacks in
117
+ `RetryManager` are unnecessary and removed in Phase A.
118
+
119
+ **Rationale**: resolving lazily through `impl` is what makes the class policy apply on every
120
+ path that already consults `step_config.retry_config`, with no per-path changes: the
121
+ in-process `RetryManager`, the background requeue, `StepWorker` for `async_step`, and resume
122
+ (FR-011/FR-012). `retry_source` covers the introspection requirement (FR-014) with one method.
123
+
124
+ **Alternatives considered**: snapshotting `impl.retry_config` at build time. Rejected: it
125
+ diverges from the lock readers and would miss a class policy declared after the reactor
126
+ references the class (for example after a reopen or reload).
127
+
128
+ ## R6 — Conflict between class and step block
129
+
130
+ **Decision**: `StepBuilder#build` gains `check_retry_conflict!`, next to
131
+ `check_coordination_conflicts!`. It raises `Error::ValidationError` when the block declared
132
+ `retries` **and** `@impl.respond_to?(:retry_config) && @impl.retry_config`. The message has
133
+ the same shape as the lock one: `"<Reactor> step :<name> declares `retries` inline, but
134
+ <Class> declares it too. Keep ONE: …"`, and it also mentions subclassing as the way to vary
135
+ the policy per workflow.
136
+
137
+ **Rationale**: FR-009 asks for the same rule as locks. An inherited class policy counts as a
138
+ declaration, since `retry_config` returns the copied value.
139
+
140
+ `ComposeBuilder` and `AsyncReactorBuilder` need no check: their `impl` is an internal
141
+ `ComposeStep`/`AsyncReactorStep` that never declares retries.
142
+
143
+ ## R7 — Direct invocation (clarified: runs once)
144
+
145
+ **Finding**: `Step.run` never enters `RetryManager`. `StepCoordination#retry_pending?` already
146
+ returns `false` for direct calls (`context_state?` is `!@direct && …`,
147
+ `step_coordination.rb:533-540, 679-681`), and the existing comment says so: "A direct call has
148
+ no retry policy of its own — it is never retried."
149
+
150
+ **Decision**: no runtime change. Add one spec proving that a step class declaring
151
+ `retries max_attempts: 3` and called directly runs once and returns its failure. Document it
152
+ in `retry_configuration.md` and next to the direct-call docs, contrasting it with locks
153
+ (FR-013).
154
+
155
+ ## R8 — Test surface (`TestSubject`, matchers)
156
+
157
+ **Finding**: `mock_step` and `failing_at` replace only `@run_block` on the step config
158
+ (`test_subject.rb:797-826`), so `impl`, and with it the class policy, is kept. The
159
+ `have_retried_step` matcher (`rspec/matchers.rb:130`) reads attempt counts from the context,
160
+ not from the declaration.
161
+
162
+ **Decision**: no new matcher. Delete the three `@retry_defaults` copies (Phase A). Add specs
163
+ proving that `failing_at`/`mock_step` on a class step still retries under the class policy
164
+ (FR-015).
165
+
166
+ ## R9 — Versioning
167
+
168
+ **Finding**: the version is `0.8.3`, managed by release-please. Earlier breaking removals
169
+ were released as `feat!` with a `⚠ BREAKING CHANGES` section and a **Migration:** line in
170
+ `CHANGELOG.md` (for example `async true`, `CHANGELOG.md:329`).
171
+
172
+ **Decision**: Phase A lands as a `feat!:` commit whose `BREAKING CHANGE:` footer carries the
173
+ migration text. Release-please generates the CHANGELOG entry from it. The pre-1.0 bump size
174
+ is release-please's call, as with earlier breaking releases. Phase B is a plain `feat:`.
175
+
176
+ ## R10 — Documentation and demo impact
177
+
178
+ **`retry_defaults` appears in**:
179
+
180
+ - `documentation/retry_configuration.md` (the "Reactor-Level Defaults" section) and
181
+ `documentation/core_concepts.md` (≈ line 360, "Uses reactor defaults");
182
+ - `documentation/background_and_async.md` (≈ lines 482-530);
183
+ - `documentation/examples/{inventory_management,order_processing,payment_processing}.md`;
184
+ - the stale copies under `demo_app/documentation/` (`async_reactors.md`, the same `examples/`
185
+ files, `retry_configuration.md`).
186
+
187
+ **README**: the Features bullet (line 24) and the Retry Configuration blurb (line 1486,
188
+ "configure retries at the reactor or step level") both need rewording.
189
+
190
+ **Demo (Constitution VI)**: there is no retry-specific demo today. Retries appear only inline
191
+ in `signal_demo_reactor.rb` and `order_processing_reactor.rb`. Add `StepRetryDemoReactor` using
192
+ class steps, the `demo:step_retry` rake task, and
193
+ `demo_app/spec/reactors/step_retry_demo_reactor_spec.rb`, modeled on
194
+ `step_lock_demo_reactor.rb` and `inheritable_step_demo_reactor_spec.rb`.
@@ -0,0 +1,453 @@
1
+ # Feature Specification: Step-Scoped Retry Declarations
2
+
3
+ **Feature Branch**: `retry_confs_in_steps`
4
+
5
+ **Created**: 2026-09-25
6
+
7
+ **Status**: Draft
8
+
9
+ **Input**: User description: "As we did previously with `locks` we have to continue moving
10
+ configurations from the main reactor to the steps. Currently:
11
+
12
+ ```ruby
13
+ class PaymentReactor < RubyReactor::Reactor
14
+ background all: true
15
+
16
+ step :charge_card, ChargeCard do
17
+ retries max_attempts: 3, backoff: :exponential, base_delay: 5.seconds
18
+ end
19
+ end
20
+ ```
21
+
22
+ But we are designing the DSL so that steps are encapsulated units of work that know the best way
23
+ to be executed. The preferred way:
24
+
25
+ ```ruby
26
+ class ChargeCard < RubyReactor::Step
27
+ with_lock(...) # already implemented
28
+ input :email, :string, format?: EMAIL_REGEX
29
+ retries max_attempts: 3 # AND!!
30
+
31
+ def run
32
+ logic
33
+ end
34
+ end
35
+ ```
36
+
37
+ As always, inline reactor steps and reactor declarations are supported and maintained. This is
38
+ valid:
39
+
40
+ ```ruby
41
+ class PaymentReactor < RubyReactor::Reactor
42
+ background all: true
43
+
44
+ step :charge_card do
45
+ retries max_attempts: 3, backoff: :exponential, base_delay: 5.seconds
46
+ run { PaymentService.charge(card_token, amount) }
47
+ end
48
+ end
49
+ ```
50
+
51
+ We have to emulate the logic and behaviour we implemented for locks and inputs."
52
+
53
+ Follow-up from the user: "And we should remove altogether the retry defaults (`retry_defaults`
54
+ on the reactor). Those are a bad practice and make retries unpredictable."
55
+
56
+ ## Clarifications
57
+
58
+ ### Session 2026-09-25
59
+
60
+ - Q: Should a direct call to a step class (outside any reactor) honor the class's retry
61
+ policy? → A: No. A direct call runs once. Only the reactor coordinates retries.
62
+
63
+ ## User Scenarios & Testing *(mandatory)*
64
+
65
+ ### User Story 1 - Remove reactor-wide retry defaults first (Priority: P1, delivered first)
66
+
67
+ An author reading a reactor must be able to tell how each step is retried by looking at that
68
+ step alone: its class or its step block. Reactor-wide retry defaults break this, because a
69
+ line at the top of a reactor silently changes how every step without its own policy behaves.
70
+ The same step class can be retried three times in one reactor and never in another, and
71
+ moving a line up or down in the reactor changes which steps it applies to.
72
+
73
+ Reactor-wide retry defaults are removed. A step that declares no policy runs once. Reactors
74
+ that still call the removed declaration fail to load with a message explaining the
75
+ replacement.
76
+
77
+ **Why this priority**: Reactor-wide defaults are the main source of unpredictable retries.
78
+ Removing them first also makes the rest of this feature simpler: once they are gone, a step's
79
+ policy can only come from two places (its step block or its step class), so no later story
80
+ has to decide how a step class policy ranks against a reactor default, or whether it depends
81
+ on where the defaults line sits in the reactor. This story is delivered and verified on its
82
+ own, before any step class declaration work starts (see Delivery order).
83
+
84
+ **Independent Test**: Define a reactor that uses the removed reactor-wide defaults declaration
85
+ and confirm it fails to load with a message naming the reactor and pointing to step-level
86
+ `retries`. Then define a reactor whose steps declare no policy and confirm each failing step
87
+ is attempted exactly once.
88
+
89
+ **Acceptance Scenarios**:
90
+
91
+ 1. **Given** a reactor that declares reactor-wide retry defaults, **When** the reactor class is
92
+ loaded, **Then** loading fails with a message naming the reactor, stating that reactor-wide
93
+ defaults were removed, and telling the author to declare `retries` on each step class or
94
+ step block that needs it.
95
+ 2. **Given** a step with no retry declaration in its class or its step block, **When** it
96
+ fails, **Then** it is attempted exactly once and the failure proceeds to rollback.
97
+ 3. **Given** a reactor that did not use reactor-wide defaults before this change, **When** it
98
+ runs after the change, **Then** every step behaves exactly as before.
99
+ 4. **Given** nested reactors placed with `compose` or `async_reactor`, **When** their step
100
+ blocks declare no `retries`, **Then** they are attempted once. They no longer read a
101
+ reactor-wide default.
102
+
103
+ ---
104
+
105
+ ### User Story 2 - A step class declares its own retry policy (Priority: P1)
106
+
107
+ A workflow author writes a step that talks to a flaky external service: charging a card,
108
+ calling a partner API, sending an email. The author knows how that operation should be retried
109
+ (how many attempts, how far apart), because the author knows the service. Today that
110
+ knowledge can only be written in each reactor that uses the step, so every reactor has to
111
+ repeat it and can get it wrong.
112
+
113
+ The author declares the retry policy in the step class, next to its inputs and its lock. Any
114
+ reactor that uses the step gets that policy without mentioning it.
115
+
116
+ **Why this priority**: This is the main feature. Without it, the step is not a complete unit
117
+ of work: part of how it should run still lives in every caller. It builds on US1: by the
118
+ time it starts, reactor-wide defaults no longer exist.
119
+
120
+ **Independent Test**: Define a step class that declares three attempts and fails on its first
121
+ two runs. Place it in a reactor with no retry wiring and confirm the step succeeds on its third
122
+ attempt. Then make it fail on every run and confirm the workflow fails after exactly three
123
+ attempts.
124
+
125
+ **Acceptance Scenarios**:
126
+
127
+ 1. **Given** a step class declaring a maximum of N attempts, **When** a reactor uses the step
128
+ without any retry wiring and the step fails with a retryable failure, **Then** the step is
129
+ attempted again, up to N attempts in total.
130
+ 2. **Given** the same step, **When** an attempt succeeds before N attempts are used,
131
+ **Then** the workflow continues with that attempt's result and makes no further attempts.
132
+ 3. **Given** the same step, **When** all N attempts fail, **Then** the step's failure reports
133
+ the step name and the number of attempts made, and rollback proceeds as for any step
134
+ failure.
135
+ 4. **Given** a step class declaring a backoff strategy and base delay, **When** attempts are
136
+ retried, **Then** the wait between attempts follows that strategy and delay.
137
+ 5. **Given** a step class declaring retries with only some options (for example only the
138
+ number of attempts), **When** it runs, **Then** the options it left out take the same
139
+ defaults the reactor-side `retries` uses today.
140
+ 6. **Given** a step fails with a failure marked as not retryable, **When** its class declares
141
+ retries, **Then** it is not attempted again, exactly as with reactor-side retries today.
142
+
143
+ ---
144
+
145
+ ### User Story 3 - Existing step-level declarations keep working unchanged (Priority: P1)
146
+
147
+ Authors already declare retries in a reactor's step block, for inline steps and for class
148
+ steps. Both keep working exactly as they do today. The class-level declaration is the
149
+ preferred form for class steps, not a replacement.
150
+
151
+ **Why this priority**: Inline steps are a supported style, and existing step-block
152
+ declarations must not break.
153
+
154
+ **Independent Test**: Run the existing step-level retry tests unchanged and confirm they pass.
155
+ Then declare `retries` inside an inline step block and confirm the behavior matches the class
156
+ form.
157
+
158
+ **Acceptance Scenarios**:
159
+
160
+ 1. **Given** an inline step that declares `retries` and a `run` body in its block, **When** it
161
+ fails, **Then** it is retried according to that declaration, exactly as today.
162
+ 2. **Given** a class step that declares no retries, **When** its reactor step block declares
163
+ `retries`, **Then** the step-block declaration applies, exactly as today.
164
+ 3. **Given** an inline step and a class step with the same retry declaration, **When** both
165
+ fail the same way, **Then** their attempt counts, delays, and final outcome are the same.
166
+ 4. **Given** an inline step's `retries` line, **When** the author moves the step into a class,
167
+ **Then** the same line works unchanged in the class body.
168
+
169
+ ---
170
+
171
+ ### User Story 4 - One declaration per step (Priority: P1)
172
+
173
+ A step's policy is declared in exactly one place. Declaring it in the step class and again in
174
+ the reactor's step block for that class is refused when the reactor is defined, the same way a
175
+ lock declared in both places is refused today.
176
+
177
+ **Why this priority**: If both declarations were accepted silently, one would be ignored and
178
+ the author would not know which. That is how retries end up wrong in production.
179
+
180
+ **Independent Test**: Declare retries on a step class, then add a `retries` line to a
181
+ reactor's step block for that class. Confirm the reactor fails to load with a message naming
182
+ the reactor, the step, and the class, and explaining how to resolve it.
183
+
184
+ **Acceptance Scenarios**:
185
+
186
+ 1. **Given** a step class declaring retries, **When** a reactor also declares `retries` in that
187
+ step's block, **Then** the reactor is refused at definition time with a message naming the
188
+ reactor, the step, and the class, and telling the author to keep only one declaration.
189
+ 2. **Given** a step class that declares no retries, **When** a reactor declares `retries` in
190
+ its step block, **Then** no conflict is raised (US3 scenario 2).
191
+ 3. **Given** a step class declaring a single attempt, **When** it runs, **Then** it is not
192
+ retried. Declaring one attempt is a valid, explicit "do not retry".
193
+
194
+ ---
195
+
196
+ ### User Story 5 - The policy follows the step on every execution path (Priority: P2)
197
+
198
+ A step class's retry policy applies wherever the step runs: in the calling process, in a
199
+ background worker after the whole reactor is handed off, after a mid-workflow hand-off, as an
200
+ independently dispatched step, and after the workflow resumes from an interrupt. Attempts
201
+ already made are remembered when a retry is scheduled for later, so the attempt limit holds
202
+ across those gaps.
203
+
204
+ **Why this priority**: A policy that only applies on some paths is a trap, and background runs
205
+ are where retries matter most. But the synchronous path alone already delivers value.
206
+
207
+ **Independent Test**: Run the same reactor with the same always-failing class step
208
+ synchronously and in the background. Confirm both make exactly the declared number of
209
+ attempts, and that the background run schedules later attempts without blocking the worker.
210
+
211
+ **Acceptance Scenarios**:
212
+
213
+ 1. **Given** a class step with a declared policy in a reactor that runs in the calling process,
214
+ **When** it fails, **Then** retries wait in-process between attempts, as reactor-side
215
+ retries do today.
216
+ 2. **Given** the same step in a reactor that runs in a background worker, **When** it fails,
217
+ **Then** the next attempt is scheduled for later instead of blocking the worker, as
218
+ reactor-side retries do today.
219
+ 3. **Given** the same step placed as an independently dispatched step, **When** it fails,
220
+ **Then** its declared policy governs its retries.
221
+ 4. **Given** a retry scheduled for later, **When** the next attempt runs, **Then** the attempts
222
+ already made count toward the limit, and the workflow never exceeds the declared maximum.
223
+ 5. **Given** a step that exhausts its attempts in any of these paths, **When** it fails for the
224
+ last time, **Then** earlier steps compensate exactly as they do for reactor-side retries.
225
+
226
+ ---
227
+
228
+ ### User Story 6 - Step subclasses inherit the policy (Priority: P2)
229
+
230
+ A team keeps a base step class for all calls to one partner API, with the retry policy that
231
+ API needs. Each concrete step inherits that policy. A subclass that needs a different policy
232
+ declares its own, and this does not change its parent or its siblings.
233
+
234
+ **Why this priority**: Step inheritance is already supported for inputs and locks. Retries
235
+ must behave the same way, but the feature works without it.
236
+
237
+ **Independent Test**: Declare retries on a base step class, subclass it twice, override the
238
+ policy in one subclass, and confirm each class runs with the expected policy.
239
+
240
+ **Acceptance Scenarios**:
241
+
242
+ 1. **Given** a base step class declaring retries, **When** a subclass declares none, **Then**
243
+ the subclass runs with the base policy.
244
+ 2. **Given** a subclass that declares its own retries, **When** it runs, **Then** its own
245
+ policy applies, and the base class and other subclasses keep the base policy.
246
+ 3. **Given** a reactor needs a different policy for a class step that already declares one,
247
+ **When** the author subclasses the step and declares the new policy there, **Then** the
248
+ reactor uses the subclass with no conflict. This is the documented way to vary the policy
249
+ per workflow.
250
+
251
+ ---
252
+
253
+ ### User Story 7 - Tests and operators can see the policy (Priority: P2)
254
+
255
+ A developer writing a spec for a reactor uses the shipped test helpers to check that a
256
+ class-declared policy was applied: how many times a step was retried, and that it failed or
257
+ succeeded afterward. Operators and tooling can find out what retry policy a step will run with
258
+ and where it was declared.
259
+
260
+ **Why this priority**: Required by the project's testing and observability rules. The feature
261
+ works without it, but cannot be verified or debugged without it.
262
+
263
+ **Independent Test**: Write a reactor spec using only the shipped test surface that makes a
264
+ class step fail and asserts it was retried the declared number of times. Then look up the
265
+ step's effective policy from the reactor and confirm it reports the class as its source.
266
+
267
+ **Acceptance Scenarios**:
268
+
269
+ 1. **Given** a class step with a declared policy, **When** it fails in a test using the shipped
270
+ test helpers, **Then** the existing retry assertions report the attempts it made.
271
+ 2. **Given** a class step whose body is replaced by a test mock, **When** the mock fails,
272
+ **Then** the class's declared policy still applies, so tests exercise the real policy.
273
+ 3. **Given** any step in a reactor, **When** its effective retry policy is looked up, **Then**
274
+ the answer includes the maximum attempts, backoff, base delay, and whether it came from the
275
+ step class, the step block, or no declaration.
276
+ 4. **Given** a class step being retried, **When** instrumentation is enabled, **Then** each
277
+ retry attempt is reported with the step name and attempt number, exactly as for
278
+ reactor-side retries today.
279
+
280
+ ---
281
+
282
+ ### Edge Cases
283
+
284
+ - **Invalid values**: an unknown backoff strategy, a negative delay, or an attempt count that
285
+ is not a positive whole number is refused when the step class (or inline step) is defined,
286
+ naming the step and the bad value. Today an unknown backoff strategy only fails at the first
287
+ retry, in production.
288
+ - **Direct invocation**: a step class that declares retries is called directly from
289
+ application code, or from another step's body, not through a reactor. The call runs once
290
+ and its retries do not apply: only a reactor coordinates retries. A failure is returned to
291
+ the caller straight away. This differs on purpose from locks, which a direct call does take.
292
+ - **Reactor subclasses and test doubles**: a reactor subclass, or a test copy of a reactor
293
+ made by the shipped test helpers, must not bring back reactor-wide defaults by copying them
294
+ from a parent.
295
+ - **Retries and locks**: a step class declares both a lock and retries. Their combined behavior
296
+ must be the same as when the same two declarations are made on the reactor side today. This
297
+ feature changes where the policy is declared, not how retries use locks.
298
+ - **Retries and input validation**: a step whose inputs fail its own contract is not retried.
299
+ Invalid inputs will not become valid on another attempt, and today reactor-side retries do
300
+ not retry contract failures either.
301
+ - **Skipped steps**: a step skipped by a condition or guard makes no attempts and uses none of
302
+ its retry budget.
303
+ - **Policy changed between attempts**: a retry scheduled for later runs under the policy in the
304
+ code that the worker is running. Attempts already made still count.
305
+ - **Duck-typed implementations**: a step implementation that is not a step class has no class
306
+ policy. Only its step-block declaration applies, and without one it runs once.
307
+ - **Nested reactors and map elements**: `compose`, `async_reactor`, and `map` place reactors,
308
+ not step classes. Their step-block `retries` declarations keep working. A step class used
309
+ inside a nested or mapped reactor brings its own policy there too.
310
+
311
+ ## Requirements *(mandatory)*
312
+
313
+ ### Functional Requirements
314
+
315
+ #### Declaration
316
+
317
+ - **FR-001**: A step class MUST be able to declare its retry policy (maximum attempts, backoff
318
+ strategy, base delay) using the same `retries` vocabulary and defaults as the reactor-side
319
+ step block.
320
+ - **FR-002**: An inline step MUST keep being able to declare `retries` in its reactor step
321
+ block, with identical behavior to the class form.
322
+ - **FR-003**: A class step that declares no retries MUST keep accepting a `retries`
323
+ declaration in its reactor step block, with today's behavior.
324
+ - **FR-004**: Invalid retry values MUST be refused at definition time, in either form, with a
325
+ message naming the step and the offending value.
326
+
327
+ #### Removal of reactor-wide defaults
328
+
329
+ - **FR-005**: The reactor-wide retry defaults declaration MUST be removed. A reactor that uses
330
+ it MUST fail to load with a message naming the reactor, stating that the declaration was
331
+ removed, and telling the author to declare `retries` on each step class or step block that
332
+ needs it. This follows how other removed reactor declarations are handled.
333
+ - **FR-006**: No step, nested reactor, or dispatched unit MAY take its retry policy from its
334
+ reactor. The only sources are the step class and the step block.
335
+ - **FR-007**: A step with no retry declaration in either place MUST be attempted exactly once.
336
+
337
+ #### Precedence and conflicts
338
+
339
+ - **FR-008**: A step's effective policy MUST come from its step block if it declares one,
340
+ otherwise from its step class, otherwise no retries.
341
+ - **FR-009**: Declaring `retries` both on a step class and in a reactor's step block for that
342
+ class MUST be refused at reactor definition time, with a message naming the reactor, the
343
+ step, and the class, and telling the author to keep one declaration. This matches how
344
+ conflicting lock declarations are refused.
345
+
346
+ #### Inheritance
347
+
348
+ - **FR-010**: A step subclass MUST inherit its parent's retry policy unless it declares its
349
+ own. Redeclaring MUST NOT affect the parent or its siblings.
350
+
351
+ #### Execution
352
+
353
+ - **FR-011**: A class-declared policy MUST apply on every path a reactor-side policy applies
354
+ to today: the calling process, background hand-off of the whole reactor or part of it,
355
+ independently dispatched steps, and runs resumed after an interrupt or a scheduled retry.
356
+ - **FR-012**: Retry behavior under a class-declared policy MUST match a step-block declaration
357
+ with the same values: which failures are retried, attempt counting across scheduled
358
+ retries, the wait between attempts, in-process waiting versus scheduling for later, the
359
+ final failure report, and compensation after the last attempt.
360
+ - **FR-013**: A direct invocation of a step class (outside any reactor, including from
361
+ another step's body) MUST run exactly once and MUST NOT apply the class's retry policy. Only
362
+ a reactor coordinates retries. Documentation MUST state this, and MUST state that it differs
363
+ from locks, which a direct call does take.
364
+
365
+ #### Visibility and verification
366
+
367
+ - **FR-014**: A step's effective retry policy and its source (step class, step block, or none)
368
+ MUST be available to tooling and tests.
369
+ - **FR-015**: The shipped test surface MUST verify class-declared retries with the existing
370
+ retry assertions, including when the step's body is replaced by a test mock.
371
+ - **FR-016**: Retry attempts under a class-declared policy MUST produce the same
372
+ instrumentation events and failure details as step-block retries.
373
+
374
+ #### Delivery order
375
+
376
+ - **FR-017**: The removal of reactor-wide defaults (US1, FR-005 to FR-007) MUST be delivered
377
+ first as a standalone change: removal, removal error, rewritten tests, documentation and
378
+ migration note, with the full suite passing. Step class declarations (US2 onward) MUST start
379
+ only after that change is complete, and MUST NOT include any handling of reactor-wide
380
+ defaults.
381
+
382
+ #### Delivery
383
+
384
+ - **FR-018**: The feature MUST ship a runnable demo reactor, a demo task, and a spec that uses
385
+ only the shipped test surface. Together they MUST show a class step that succeeds after
386
+ retries, a class step that exhausts its retries and triggers compensation, and a step with
387
+ no declaration that is attempted once.
388
+ - **FR-019**: Documentation MUST present the step class form as the preferred way to declare
389
+ retries, and MUST state the precedence order, the conflict rule, and how to vary a policy per
390
+ workflow by subclassing. Every example that uses reactor-wide defaults MUST be rewritten to
391
+ step-level declarations.
392
+ - **FR-020**: The release MUST carry a migration note explaining the removal of reactor-wide
393
+ defaults and showing how to move each default onto the steps that need it.
394
+
395
+ ### Key Entities
396
+
397
+ - **Retry Policy**: how a step is re-attempted after a retryable failure. Attributes: maximum
398
+ attempts, backoff strategy (exponential, linear, fixed), base delay.
399
+ - **Policy Source**: where a step's effective policy came from. One of: step block (inline or
400
+ reactor-side), step class, or none.
401
+ - **Attempt Record**: the per-execution count of attempts made for each step. It is kept
402
+ across scheduled retries and hand-offs, and checked against the effective policy's maximum.
403
+
404
+ ## Success Criteria *(mandatory)*
405
+
406
+ ### Measurable Outcomes
407
+
408
+ - **SC-001**: A step class that declares its retry policy can be used in any number of reactors
409
+ with zero retry lines in those reactors, and gets the same policy in each.
410
+ - **SC-002**: Every existing step-level retry test passes unchanged. Tests that exercised
411
+ reactor-wide defaults are replaced by tests of the removal error.
412
+ - **SC-003**: For the same failure sequence, a class-declared policy and a step-block
413
+ declaration with the same values produce the same attempt count, the same waits between
414
+ attempts, and the same final outcome, on both the in-process and the background paths.
415
+ - **SC-004**: A step's retry policy can be determined by reading only that step's class or
416
+ step block, in 100% of cases.
417
+ - **SC-005**: 100% of reactors still using reactor-wide defaults, and 100% of conflicting
418
+ double declarations (class plus step block), are refused when the reactor loads. None reach
419
+ run time.
420
+ - **SC-006**: 100% of invalid retry values are refused when the step is defined. None surface
421
+ first at a retry in production.
422
+ - **SC-007**: A developer can find a step's effective policy and where it was declared without
423
+ reading the step or reactor source.
424
+ - **SC-008**: The demo runs end to end in the project's container setup and shows the
425
+ succeed-after-retry, exhaust-and-compensate, and single-attempt outcomes.
426
+
427
+ ## Assumptions
428
+
429
+ - The audience is developers writing reactors and steps with this library.
430
+ - The feature changes where the retry policy is declared, not how retries run. The runtime
431
+ behavior of retries (retryable failures, backoff formulas, attempt counting, scheduling in
432
+ background runs) is reused unchanged.
433
+ - Delivering the removal first is deliberate: it deletes the reactor-default fallback before
434
+ a second source of policy (the step class) is added, so the new work only ever deals with
435
+ two sources and needs no ordering or precedence rules involving the reactor.
436
+ - Removing reactor-wide defaults is a breaking change to the public API. It is released under
437
+ the project's versioning policy for breaking changes, with a migration note. It follows the
438
+ existing pattern for removed declarations: the old call raises a clear error at load time
439
+ instead of being silently ignored.
440
+ - A reactor that never declared reactor-wide defaults already runs undeclared steps once, so
441
+ it sees no behavior change.
442
+ - The conflict rule for class-plus-step-block declarations follows the existing rule for
443
+ conflicting lock declarations: refuse, don't silently choose one. To use a different policy
444
+ in one workflow, the author subclasses the step (US6 scenario 3). There is no per-reactor
445
+ override of a class policy.
446
+ - The step-block `retries` form stays fully supported for inline steps, for class steps that
447
+ declare no policy, and for `compose`, `async_reactor`, and `map`. It is not deprecated in
448
+ this feature.
449
+ - Refusing invalid values at definition time tightens the inline form slightly. A value that
450
+ fails at the first retry today will fail when the reactor loads instead. Values that work
451
+ today keep working, with one exception: an attempt count of zero, which today silently means
452
+ "do not retry", must now be written as one.
453
+ - Step inheritance works for retries the same way it already works for inputs and locks.