ruby_reactor 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/speckit-review/SKILL.md +324 -0
  3. data/.release-please-manifest.json +1 -1
  4. data/.specify/extensions.yml +10 -0
  5. data/.specify/feature.json +1 -1
  6. data/.specify/workflows/speckit/workflow.yml +13 -1
  7. data/.specify/workflows/workflow-registry.json +2 -2
  8. data/CHANGELOG.md +94 -0
  9. data/CLAUDE.md +2 -2
  10. data/README.md +128 -93
  11. data/lib/ruby_reactor/adapters/active_job/router.rb +19 -0
  12. data/lib/ruby_reactor/adapters/sidekiq/router.rb +21 -0
  13. data/lib/ruby_reactor/context.rb +26 -0
  14. data/lib/ruby_reactor/context_serializer.rb +4 -2
  15. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +3 -6
  16. data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
  17. data/lib/ruby_reactor/dsl/interrupt_builder.rb +14 -0
  18. data/lib/ruby_reactor/dsl/lockable.rb +76 -21
  19. data/lib/ruby_reactor/dsl/reactor.rb +8 -11
  20. data/lib/ruby_reactor/dsl/retryable.rb +45 -0
  21. data/lib/ruby_reactor/dsl/step_builder.rb +150 -13
  22. data/lib/ruby_reactor/error/async_result_pending.rb +1 -1
  23. data/lib/ruby_reactor/error/execution_parked.rb +16 -0
  24. data/lib/ruby_reactor/error/reactor_contention_park.rb +26 -0
  25. data/lib/ruby_reactor/error/step_contention_park.rb +26 -0
  26. data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
  27. data/lib/ruby_reactor/executor/async_step_dispatch.rb +109 -3
  28. data/lib/ruby_reactor/executor/compensation_manager.rb +99 -17
  29. data/lib/ruby_reactor/executor/ordered_lock_support.rb +76 -44
  30. data/lib/ruby_reactor/executor/result_handler.rb +34 -13
  31. data/lib/ruby_reactor/executor/retry_manager.rb +20 -13
  32. data/lib/ruby_reactor/executor/step_coordination.rb +788 -0
  33. data/lib/ruby_reactor/executor/step_executor.rb +115 -11
  34. data/lib/ruby_reactor/executor.rb +90 -20
  35. data/lib/ruby_reactor/map/element_executor.rb +24 -2
  36. data/lib/ruby_reactor/map/helpers.rb +35 -11
  37. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +2 -2
  38. data/lib/ruby_reactor/open_telemetry.rb +61 -24
  39. data/lib/ruby_reactor/retry_context.rb +31 -2
  40. data/lib/ruby_reactor/rspec/helpers.rb +15 -0
  41. data/lib/ruby_reactor/rspec/matchers.rb +92 -0
  42. data/lib/ruby_reactor/rspec/test_subject.rb +9 -5
  43. data/lib/ruby_reactor/step/async_reactor_step.rb +46 -26
  44. data/lib/ruby_reactor/step/compose_step.rb +22 -7
  45. data/lib/ruby_reactor/step/input_contract.rb +5 -0
  46. data/lib/ruby_reactor/step/inputs.rb +57 -0
  47. data/lib/ruby_reactor/step/map_step.rb +32 -22
  48. data/lib/ruby_reactor/step.rb +59 -11
  49. data/lib/ruby_reactor/step_sweeper.rb +29 -1
  50. data/lib/ruby_reactor/step_worker.rb +260 -37
  51. data/lib/ruby_reactor/version.rb +1 -1
  52. data/lib/ruby_reactor/web/api.rb +72 -7
  53. data/lib/ruby_reactor/web/coordination_serializer.rb +120 -2
  54. data/lib/ruby_reactor/web/public/assets/{index-Dw4KV4QY.js → index-CeZU-ESu.js} +9 -9
  55. data/lib/ruby_reactor/web/public/index.html +1 -1
  56. data/lib/ruby_reactor/worker.rb +56 -30
  57. data/lib/ruby_reactor.rb +30 -6
  58. data/specs/{004-inheritable-step-class → 006-step-retry-declarations}/checklists/requirements.md +10 -9
  59. data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
  60. data/specs/006-step-retry-declarations/data-model.md +58 -0
  61. data/specs/006-step-retry-declarations/plan.md +187 -0
  62. data/specs/006-step-retry-declarations/quickstart.md +80 -0
  63. data/specs/006-step-retry-declarations/research.md +194 -0
  64. data/specs/006-step-retry-declarations/spec.md +453 -0
  65. data/specs/006-step-retry-declarations/tasks.md +382 -0
  66. data/specs/future_improvements.md +250 -0
  67. data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
  68. metadata +20 -28
  69. data/specs/002-step-input-contracts/checklists/requirements.md +0 -49
  70. data/specs/002-step-input-contracts/contracts/dsl-surface.md +0 -193
  71. data/specs/002-step-input-contracts/data-model.md +0 -115
  72. data/specs/002-step-input-contracts/plan.md +0 -165
  73. data/specs/002-step-input-contracts/quickstart.md +0 -170
  74. data/specs/002-step-input-contracts/research.md +0 -233
  75. data/specs/002-step-input-contracts/spec.md +0 -359
  76. data/specs/002-step-input-contracts/tasks.md +0 -367
  77. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +0 -85
  78. data/specs/004-inheritable-step-class/data-model.md +0 -116
  79. data/specs/004-inheritable-step-class/plan.md +0 -174
  80. data/specs/004-inheritable-step-class/quickstart.md +0 -112
  81. data/specs/004-inheritable-step-class/research.md +0 -308
  82. data/specs/004-inheritable-step-class/spec.md +0 -316
  83. data/specs/004-inheritable-step-class/tasks.md +0 -258
  84. data/specs/active_job.md +0 -259
  85. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +0 -51
  86. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +0 -154
  87. data/specs/deferred-003-step-lock-declarations/data-model.md +0 -131
  88. data/specs/deferred-003-step-lock-declarations/plan.md +0 -166
  89. data/specs/deferred-003-step-lock-declarations/quickstart.md +0 -169
  90. data/specs/deferred-003-step-lock-declarations/research.md +0 -196
  91. data/specs/deferred-003-step-lock-declarations/spec.md +0 -447
  92. data/specs/deferred-003-step-lock-declarations/tasks.md +0 -572
  93. data/specs/possible_feature.md +0 -22
@@ -0,0 +1,382 @@
1
+ ---
2
+
3
+ description: "Task list for Step-Scoped Retry Declarations"
4
+ ---
5
+
6
+ # Tasks: Step-Scoped Retry Declarations
7
+
8
+ **Input**: Design documents from `specs/006-step-retry-declarations/`
9
+
10
+ **Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md),
11
+ [data-model.md](data-model.md), [contracts/dsl-surface.md](contracts/dsl-surface.md),
12
+ [quickstart.md](quickstart.md)
13
+
14
+ **Tests**: REQUIRED. Constitution III requires test-first: write every spec task first and
15
+ see it fail before starting the implementation tasks in the same phase. Run specs against
16
+ real Redis. Background-path specs must use **constant-named** reactor and step classes (as in
17
+ `spec/ruby_reactor/retry_signals_spec.rb`), because a worker rehydrates reactors by class
18
+ name.
19
+
20
+ **Delivery order (spec FR-017)**: Phase 2 (US1, removing `retry_defaults`) is committed as a
21
+ standalone `feat!` with the suite green **before any later phase starts**. No later task may
22
+ read or mention `retry_defaults`, except the removal stub and its spec.
23
+
24
+ ## Format: `[ID] [P?] [Story] Description`
25
+
26
+ - **[P]**: can run in parallel (different files, no dependency on an incomplete task)
27
+ - **[Story]**: the user story from spec.md (US1–US7)
28
+
29
+ ## Path Conventions
30
+
31
+ Gem: `lib/ruby_reactor/`, `spec/`. Demo: `demo_app/`. New feature specs:
32
+ `spec/ruby_reactor/step_retries/` (one file per story).
33
+
34
+ ---
35
+
36
+ ## Phase 1: Setup
37
+
38
+ **Purpose**: record a green baseline so the regressions in the later phases can be told apart.
39
+
40
+ - [X] T001 Confirm Redis is reachable and record the baseline: run `bundle exec rspec spec/async_retry_dsl_spec.rb spec/async_retry_integration_spec.rb spec/ruby_reactor_spec.rb spec/ruby_reactor/retry_signals_spec.rb spec/ruby_reactor/retry_reexecution_spec.rb spec/ruby_reactor/order_processing_reactor_spec.rb` and `bundle exec rubocop` from the repo root. Note any pre-existing failures in the PR description (see the flaky-spec note: rerun a failing file alone before treating it as real).
41
+ - [X] T002 Create the directory `spec/ruby_reactor/step_retries/` for the new feature specs.
42
+
43
+ ---
44
+
45
+ ## Phase 2: User Story 1 — Remove reactor-wide retry defaults first (Priority: P1) 🎯 MVP, ships first
46
+
47
+ **Goal**: `retry_defaults` no longer exists. Calling it fails at class definition. A step
48
+ with no `retries` runs once. Nothing reads a reactor-level default.
49
+
50
+ **Independent Test**: `Class.new(RubyReactor::Reactor) { retry_defaults max_attempts: 3 }`
51
+ raises `DeprecatedDslError`. A reactor whose failing step declares nothing runs it once.
52
+ `grep -rn retry_defaults lib spec demo_app documentation README.md llms*.txt` matches only
53
+ the stub and its spec.
54
+
55
+ ### Tests for User Story 1 (write first, confirm failing)
56
+
57
+ - [X] T003 [US1] In `spec/async_retry_dsl_spec.rb`, replace the example `"supports retry_defaults class method"` (lines 23-33) with `"rejects the removed reactor-level retry_defaults"`. It should expect `Class.new(RubyReactor::Reactor) { retry_defaults max_attempts: 5, backoff: :linear, base_delay: 2 }` to raise `RubyReactor::Error::DeprecatedDslError` with a message matching `/retry_defaults.*removed/m` and `/retries/`. Add a second example: calling `retry_defaults` with no arguments on a reactor class raises the same error.
58
+ - [X] T004 [P] [US1] Create `spec/ruby_reactor/step_retries/removal_spec.rb` with these cases:
59
+ - (a) an anonymous reactor whose step `run` always returns `Failure("boom")`, with no `retries` anywhere, returns a failure after exactly 1 attempt (`reactor.context.retry_context.attempts_for_step(:x) == 1`, error not prefixed "failed after");
60
+ - (b) the same for a `compose` step whose block declares no `retries`: `steps[:c].retry_config[:max_attempts] == 1`;
61
+ - (c) the same for `async_reactor`: `steps[:a].retry_config[:max_attempts] == 1`;
62
+ - (d) the `DeprecatedDslError` message names the reactor when the class is constant-named (define `RemovalSpecNamedReactor` via `stub_const` + `Class.new`, then call `retry_defaults` inside `class_eval`).
63
+
64
+ ### Implementation for User Story 1
65
+
66
+ - [X] T005 [US1] In `lib/ruby_reactor/dsl/reactor.rb`:
67
+ - delete line 14 (`base.instance_variable_set(:@retry_defaults, …)`);
68
+ - replace `def retry_defaults(**kwargs) … end` (lines 58-68) with `def retry_defaults(*, **)`, which raises `RubyReactor::Error::DeprecatedDslError`. Model the message on the `async` removal stub at lines 48-56 and follow the shape in `contracts/dsl-surface.md` §1: "`retry_defaults` has been removed from #{name || "this reactor"}: reactor-wide defaults silently applied only to steps declared after them. Declare `retries` on each step class (or step block) that should retry; a step with no `retries` runs once."
69
+ - [X] T006 [US1] In `lib/ruby_reactor/dsl/step_builder.rb`:
70
+ - initialize `@retry_config = nil` (line 33, currently `{}`);
71
+ - in `build` (line 163), pass `retry_config: @retry_config`;
72
+ - in `StepConfig`, add `NO_RETRIES = { max_attempts: 1, backoff: :exponential, base_delay: 1 }.freeze` and change line 271 to `@retry_config = config[:retry_config] || NO_RETRIES`.
73
+
74
+ Keep `StepBuilder#retries` for now (it is replaced in Phase 3).
75
+ - [X] T007 [P] [US1] In `lib/ruby_reactor/dsl/compose_builder.rb`, initialize `@retry_config = nil` (line 23) and pass `retry_config: @retry_config` in `build` (line 74).
76
+ - [X] T008 [P] [US1] In `lib/ruby_reactor/dsl/async_reactor_builder.rb`, initialize `@retry_config = nil` (line 20) and pass `retry_config: @retry_config` in `build` (line 52).
77
+ - [X] T009 [P] [US1] In `lib/ruby_reactor/executor/retry_manager.rb` (lines 27-35), make `calculate_backoff_delay` read only `step_config.retry_config[:backoff]` and `[:base_delay]`. Drop the now-unused `reactor_class` parameter from `calculate_backoff_delay` and from its callers `requeue_job_for_step_retry` and `handle_sync_retry` (keep `reactor_class` wherever it is still used, e.g. for `async?` and `MaxRetriesExhaustedFailure`).
78
+ - [X] T010 [P] [US1] In `lib/ruby_reactor/rspec/test_subject.rb`, delete the three `@retry_defaults = superclass.instance_variable_get(:@retry_defaults)` lines (559, 651, 739).
79
+ - [X] T011 [US1] Run `grep -rn "retry_config" lib` and check that no consumer calls `.empty?` on a builder's `@retry_config` or assumes it is a Hash before `StepConfig` normalizes it. Fix any hit in place.
80
+ - [X] T012 [P] [US1] Migrate `spec/ruby_reactor_spec.rb`:
81
+ - remove `retry_defaults max_attempts: 3` (line 14) and add `retries max_attempts: 3` inside the `validate_email`, `hash_password` and `create_user` step blocks;
82
+ - remove `retry_defaults max_attempts: 3` (line 221) and add `retries max_attempts: 3` inside `step :flaky_step`.
83
+
84
+ Do not change any assertion.
85
+ - [X] T013 [P] [US1] Migrate `spec/support/order_processing_reactor.rb`: remove `retry_defaults max_attempts: 5, backoff: :fixed, base_delay: 2` (line 28) and add `retries max_attempts: 5, backoff: :fixed, base_delay: 2` to each step without its own `retries` (`validate_order`, `check_inventory`, `reserve_inventory`, `process_payment`). Do not change `spec/ruby_reactor/order_processing_reactor_spec.rb`.
86
+ - [X] T014 [P] [US1] Apply the identical migration to `demo_app/spec/support/order_processing_reactor.rb` (the file is byte-identical to the gem copy today; keep it that way).
87
+ - [X] T015 [US1] Run `bundle exec rspec` (full suite) and `bundle exec rubocop`; everything is green, including T003/T004.
88
+
89
+ ### Documentation for User Story 1 (REQUIRED, Constitution Development Workflow)
90
+
91
+ - [X] T016 [P] [US1] In `documentation/retry_configuration.md`:
92
+ - delete "### Reactor-Level Defaults" (lines 48-70);
93
+ - rewrite the "Complex Retry Scenarios" example (≈ line 176) so each step declares its own `retries`;
94
+ - change the intro sentence "configured at both reactor and step levels" to step level only;
95
+ - add a section "## Migrating from `retry_defaults`" with a before/after example (move the values onto each step that needs them; a step without `retries` runs once; `max_attempts: 0` is not valid, use `1`).
96
+ - [X] T017 [P] [US1] In `documentation/core_concepts.md`:
97
+ - line 327: drop "either reactor-level defaults or";
98
+ - line 345: "Retries are configured per step";
99
+ - ≈ line 360: replace the "Uses reactor defaults" step with an explicit `retries` line or no retries, and adjust the surrounding example so it no longer declares `retry_defaults`.
100
+ - [X] T018 [P] [US1] In `documentation/background_and_async.md`, delete "### Reactor-Level Defaults" (≈ lines 514-530) and adjust any cross-reference to it.
101
+ - [X] T019 [P] [US1] In `documentation/examples/order_processing.md` (line 95), `documentation/examples/payment_processing.md` (lines 77, 228, 300) and `documentation/examples/inventory_management.md` (lines 51, 461), replace each `retry_defaults …` with `retries …` on the step(s) in that example that do external I/O. Keep the same values.
102
+ - [X] T020 [P] [US1] Apply the same changes to the stale copies under `demo_app/documentation/`: `retry_configuration.md` (48-70, 173), `core_concepts.md` (220, 238, 253), `async_reactors.md` (394-404), and `examples/{payment_processing (47, 230, 302, 389), order_processing (47), inventory_management (49, 460)}.md`.
103
+ - [X] T021 [P] [US1] In `README.md` line 1486, change "how to configure retries at the reactor or step level" to "how to configure retries per step". Run `grep -n "retry_defaults" llms.txt llms-full.txt` and fix any hit.
104
+ - [X] T022 [US1] Gate: `grep -rn "retry_defaults" lib spec demo_app documentation README.md llms.txt llms-full.txt` matches only `lib/ruby_reactor/dsl/reactor.rb`, `spec/async_retry_dsl_spec.rb` and `spec/ruby_reactor/step_retries/removal_spec.rb`. `bundle exec rspec` and `bundle exec rubocop` are green. Commit as `feat!: remove reactor-wide retry_defaults`, with a `BREAKING CHANGE:` footer carrying the migration text from T016 (including the `max_attempts: 0` → `1` note, which applies from Phase 3).
105
+
106
+ **Checkpoint**: US1 is shippable on its own. Do not start Phase 3 until T022 is committed.
107
+
108
+ ---
109
+
110
+ ## Phase 3: Foundational — shared `retries` vocabulary (blocks US2–US7)
111
+
112
+ **Purpose**: one `Dsl::Retryable` module gives step classes and every builder the same
113
+ `retries` keyword arguments, defaults and validation (research R3, R4; FR-001, FR-002,
114
+ FR-004).
115
+
116
+ **⚠️ CRITICAL**: no US2–US7 work until this phase is green.
117
+
118
+ - [X] T023 [P] Create `spec/ruby_reactor/step_retries/declaration_spec.rb` (validation, FR-004). For both a step class (`Class.new(RubyReactor::Step) { retries … }`) and an inline step block (`Class.new(RubyReactor::Reactor) { step(:s) { retries …; run { Success() } } }`):
119
+ - `retries` with no args stores `{max_attempts: 3, backoff: :exponential, base_delay: 1}`;
120
+ - `retries max_attempts: 5` keeps the other defaults;
121
+ - `ArgumentError` for `max_attempts: 0`, `-1`, `2.5` and `"3"` (message names the owner and `max_attempts`), for `backoff: :bogus` (names `backoff`) and for `base_delay: -1` (names `base_delay`);
122
+ - `base_delay: 0` and `base_delay: 0.5` are accepted.
123
+
124
+ Also assert that `compose` and `async_reactor` blocks raise the same `ArgumentError` for `backoff: :bogus`.
125
+ - [X] T024 Create `lib/ruby_reactor/dsl/retryable.rb` defining `RubyReactor::Dsl::Retryable` with:
126
+ - `BACKOFF_STRATEGIES = %i[exponential linear fixed].freeze`;
127
+ - `attr_reader :retry_config`;
128
+ - `retries(max_attempts: 3, backoff: :exponential, base_delay: 1)`, which validates as in `data-model.md` (Integer >= 1; strategy in the list; Numeric >= 0) and raises `ArgumentError, "#{retry_owner_label}: retries #{option} must be … (got #{value.inspect})"`, then sets `@retry_config = { max_attempts:, backoff:, base_delay: }`;
129
+ - `inherited(subclass)`: `super`, then copy `@retry_config` to the subclass when set (mirror `Dsl::Lockable::ClassMethods#inherited`, `lib/ruby_reactor/dsl/lockable.rb:33-40`);
130
+ - a private `retry_owner_label` returning `name&.to_s || inspect`.
131
+
132
+ Add `require_relative "ruby_reactor/dsl/retryable"` in `lib/ruby_reactor.rb` right after the `dsl/lockable` require (line 9).
133
+ - [X] T025 In `lib/ruby_reactor/step.rb`, add `extend RubyReactor::Dsl::Retryable` next to `extend RubyReactor::Dsl::Lockable::ClassMethods` (line 36), and update the class header comment ("The one `extend` is …") to mention both modules.
134
+ - [X] T026 In `lib/ruby_reactor/dsl/step_builder.rb`, `include RubyReactor::Dsl::Retryable`, delete `StepBuilder#retries` (lines 133-139), and remove `:retry_config` from the `attr_accessor` list (line 16). The module's reader replaces it.
135
+ - [X] T027 [P] In `lib/ruby_reactor/dsl/compose_builder.rb`, `include RubyReactor::Dsl::Retryable` and delete `ComposeBuilder#retries` (lines 47-53).
136
+ - [X] T028 [P] In `lib/ruby_reactor/dsl/async_reactor_builder.rb`, `include RubyReactor::Dsl::Retryable` and delete `AsyncReactorBuilder#retries` (lines 28-30).
137
+ - [X] T029 Run T023 plus `bundle exec rspec spec/async_retry_dsl_spec.rb spec/ruby_reactor/retry_signals_spec.rb spec/compose_spec.rb spec/ruby_reactor/dsl/` and `bundle exec rubocop lib/ruby_reactor/dsl/retryable.rb`. All green.
138
+
139
+ **Checkpoint**: the `retries` vocabulary is shared. Step classes can declare it, but reactors
140
+ don't read it yet.
141
+
142
+ ---
143
+
144
+ ## Phase 4: User Story 2 — A step class declares its own retry policy (Priority: P1)
145
+
146
+ **Goal**: a reactor using a step class with no retry wiring gets the class's policy (FR-001,
147
+ FR-008 fallback, FR-011, FR-012). A direct call runs once (FR-013).
148
+
149
+ **Independent Test**: a class step declaring `retries max_attempts: 3, base_delay: 0`, whose
150
+ body fails twice, succeeds on attempt 3 in a reactor that has no `retries` line. When the body
151
+ always fails, the reactor fails after 3 attempts and compensates the earlier steps.
152
+
153
+ ### Tests for User Story 2 (write first, confirm failing)
154
+
155
+ - [X] T030 [P] [US2] Create `spec/ruby_reactor/step_retries/class_policy_spec.rb` with:
156
+ - (a) fail twice then succeed → success, `attempts_for_step == 3`;
157
+ - (b) always fail → `RubyReactor::MaxRetriesExhaustedFailure` with message `"Step 'charge' failed after 3 attempts: …"`, and an earlier step's `compensate`/`undo` ran;
158
+ - (c) `retries max_attempts: 3, backoff: :linear, base_delay: 0.01` → the sleeps between attempts follow linear backoff: `allow_any_instance_of(RubyReactor::Executor::RetryManager).to receive(:sleep)`, then expect it to have received `sleep(0.01)` and then `sleep(0.02)`;
159
+ - (d) `retries max_attempts: 3` only → `steps[:charge].retry_config == {max_attempts: 3, backoff: :exponential, base_delay: 1}`;
160
+ - (e) a body doing `fail!(StandardError.new("x"), retry: false)` makes 1 attempt;
161
+ - (f) a step class with `input :amount, :integer` given `amount: "x"` makes 1 attempt (contract failure is non-retryable);
162
+ - (g) the step skipped by `where { false }` makes 0 attempts.
163
+ - [X] T031 [P] [US2] In `spec/ruby_reactor/step_retries/class_policy_spec.rb`, add a `describe "direct invocation"` example (FR-013): a step class with `retries max_attempts: 3` whose `run` counts calls and returns `Failure("x")`; `klass.run({})` returns a `Failure`, and the counter is 1. Repeat with the step called from another class step's `run` body inside a reactor: the inner direct call runs once per outer attempt.
164
+
165
+ ### Implementation for User Story 2
166
+
167
+ - [X] T032 [US2] In `StepConfig` (`lib/ruby_reactor/dsl/step_builder.rb`), store the raw declaration (`@retry_config = config[:retry_config]`, no default). Remove `:retry_config` from `StepConfig`'s `attr_reader` and add, next to `lock_config` (≈ line 286): `def retry_config = @retry_config || (impl.retry_config if impl.respond_to?(:retry_config)) || NO_RETRIES`. Confirm that `retryable?` (≈ line 376) still reads through it.
168
+ - [X] T033 [US2] Check (no change expected, research R7) that `lib/ruby_reactor/executor/step_coordination.rb` `retry_pending?` (lines 533-540) is guarded by `context_state?` before touching `step_config.retry_config`. When `step_config` is a `Step` class, `retry_config` may be `nil` and must never be indexed. If there is any unguarded path, guard it with `&.` there.
169
+ - [X] T034 [US2] Run T030/T031 and `bundle exec rspec spec/ruby_reactor/step_spec.rb spec/ruby_reactor/step_inheritance_spec.rb spec/ruby_reactor/step_contract_retryable_spec.rb`. All green.
170
+
171
+ **Checkpoint**: the core feature works on the in-process path.
172
+
173
+ ---
174
+
175
+ ## Phase 5: User Story 3 — Existing step-level declarations keep working (Priority: P1)
176
+
177
+ **Goal**: inline and step-block `retries` behave exactly as before, and the same line works
178
+ in a class body (FR-002, FR-003).
179
+
180
+ **Independent Test**: the existing retry specs pass unchanged, and an inline step and a class
181
+ step with the same declaration give identical outcomes.
182
+
183
+ - [X] T035 [P] [US3] Create `spec/ruby_reactor/step_retries/step_block_parity_spec.rb` with:
184
+ - (a) an inline step with `retries max_attempts: 3, backoff: :fixed, base_delay: 0` and a `run` block, failing twice → success on attempt 3;
185
+ - (b) a class step with **no** `retries` plus `step :s, Klass do retries max_attempts: 3, base_delay: 0 end` → retried 3 times, and `steps[:s].retry_config[:max_attempts] == 3`;
186
+ - (c) a parity table: the same failure sequence (fail, fail, succeed) and (always fail) run through an inline step and through a class step declaring the identical `retries` line → equal attempt counts, equal final result class, equal error message.
187
+ - [X] T036 [US3] Run the existing retry specs unchanged: `spec/async_retry_dsl_spec.rb`, `spec/async_retry_integration_spec.rb`, `spec/ruby_reactor/retry_signals_spec.rb`, `spec/ruby_reactor/retry_reexecution_spec.rb`, `spec/ruby_reactor/order_processing_reactor_spec.rb`, `spec/compose_spec.rb`. All green, with no edits to them in this phase.
188
+
189
+ ---
190
+
191
+ ## Phase 6: User Story 4 — One declaration per step (Priority: P1)
192
+
193
+ **Goal**: `retries` on both the class and the step block is refused at reactor definition
194
+ (FR-009).
195
+
196
+ **Independent Test**: adding `retries` to a reactor step block for a class that declares
197
+ `retries` raises `Error::ValidationError` naming the reactor, the step and the class.
198
+
199
+ - [X] T037 [P] [US4] Create `spec/ruby_reactor/step_retries/conflict_spec.rb` with:
200
+ - (a) a class with `retries max_attempts: 3` plus `step :charge, Klass do retries max_attempts: 5 end` → `RubyReactor::Error::ValidationError` matching `/step :charge declares `retries` inline, but .* declares it too/` and `/subclass/`;
201
+ - (b) the same with the class's policy **inherited** from a parent → still refused;
202
+ - (c) a class without `retries` plus a block `retries` → no error;
203
+ - (d) a class with `retries max_attempts: 1` in a reactor → not retried (`attempts == 1`).
204
+ - [X] T038 [US4] In `lib/ruby_reactor/dsl/step_builder.rb`, add a private `check_retry_conflict!` next to `check_coordination_conflicts!` (≈ line 180) and call it from `build`. It returns unless `@retry_config && @impl.respond_to?(:retry_config) && @impl.retry_config`, then raises `Error::ValidationError`: "#{reactor_label} step :#{@name} declares `retries` inline, but #{@impl} declares it too. Keep ONE: drop the inline declaration to use #{@impl}'s, or remove it from #{@impl}. To vary the policy per workflow, subclass #{@impl} and declare `retries` there."
205
+ - [X] T039 [US4] Run T037 and `spec/ruby_reactor/step_coordination/declaration_spec.rb` (to check the neighboring lock conflict check is untouched). Green.
206
+
207
+ ---
208
+
209
+ ## Phase 7: User Story 5 — The policy follows the step on every execution path (Priority: P2)
210
+
211
+ **Goal**: a class policy applies in background hand-off, mid-workflow hand-off,
212
+ `async_step`, and resume, and attempts carry across requeues (FR-011, FR-012).
213
+
214
+ **Independent Test**: the same always-failing class step makes exactly the declared number of
215
+ attempts synchronously and under `background all: true`.
216
+
217
+ - [X] T040 [P] [US5] Create `spec/ruby_reactor/step_retries/execution_paths_spec.rb` with constant-named classes defined at the top of the file (`StepRetriesPathsChargeStep < RubyReactor::Step` with `retries max_attempts: 3, backoff: :fixed, base_delay: 0` and a class-level attempt counter, plus reactors below). Cases:
218
+ - (a) a reactor with `background all: true`, run and drained (follow the pattern in `spec/ruby_reactor/retry_signals_spec.rb` / `spec/async_retry_integration_spec.rb`), always failing → final context failed, 3 attempts, earlier step compensated;
219
+ - (b) fail twice then succeed under `background all: true` → success, and `retry_context.attempts_for_step` stayed consistent across requeues;
220
+ - (c) a reactor with `background after: :first_step` → same count as (a);
221
+ - (d) `async_step :charge, StepRetriesPathsChargeStep` → `StepWorker` retries 3 times (`lib/ruby_reactor/step_worker.rb:344-349` reads `step_config.retry_config`);
222
+ - (e) a reactor with an `interrupt` before `:charge`, resumed, then failing → 3 attempts after the resume.
223
+ - [X] T041 [US5] Run T040. No runtime change is expected (research R5). If (d) fails, fix `lib/ruby_reactor/step_worker.rb` so it reads only `step_config.retry_config` (never `@retry_config` or `impl` directly). If (a)–(c) fail, fix the same kind of issue in `lib/ruby_reactor/executor/retry_manager.rb`.
224
+
225
+ ---
226
+
227
+ ## Phase 8: User Story 6 — Step subclasses inherit the policy (Priority: P2)
228
+
229
+ **Goal**: subclasses inherit and can override, and the parent is unaffected (FR-010).
230
+
231
+ **Independent Test**: a base class with `retries max_attempts: 4`, one subclass that inherits
232
+ it and one that overrides with 2 → 4/4/2 attempts respectively.
233
+
234
+ - [X] T042 [P] [US6] Create `spec/ruby_reactor/step_retries/inheritance_spec.rb` with:
235
+ - (a) a subclass without `retries` → `retry_config` equals the parent's, and in a reactor it makes 4 attempts;
236
+ - (b) a subclass with `retries max_attempts: 2` → 2 attempts; the parent and a sibling still show 4;
237
+ - (c) a subclass-per-workflow: `ReactorA` uses the base class and `ReactorB` uses a subclass with its own policy; both load without a conflict error and retry as declared;
238
+ - (d) a subclass also inherits the parent's `with_lock` and `input` contract alongside `retries` (regression guard that `Retryable#inherited` calls `super`).
239
+ - [X] T043 [US6] Run T042. If (d) fails, fix the `super` chain in `lib/ruby_reactor/dsl/retryable.rb` `inherited`.
240
+
241
+ ---
242
+
243
+ ## Phase 9: User Story 7 — Tests and operators can see the policy (Priority: P2)
244
+
245
+ **Goal**: the effective policy and its source can be looked up, and the shipped test surface
246
+ exercises class policies (FR-014, FR-015, FR-016).
247
+
248
+ **Independent Test**: `steps[:charge].retry_source == :step_class`, and `failing_at(:charge)`
249
+ on a class step is retried under the class policy (`have_retried_step(:charge).times(2)`).
250
+
251
+ - [X] T044 [P] [US7] Create `spec/ruby_reactor/step_retries/introspection_spec.rb` with:
252
+ - (a) `retry_source` is `:step_class`, `:step_block` or `:none` for the three kinds of steps;
253
+ - (b) `retry_config` for each matches the declaration or `NO_RETRIES`;
254
+ - (c) with a middleware registered (see `spec/ruby_reactor/middleware_spec.rb` for the pattern), a failing class step with `retries max_attempts: 3, base_delay: 0` emits `:retry_attempt` twice, with the step name and attempt numbers 1 and 2.
255
+ - [X] T045 [P] [US7] Create `spec/ruby_reactor/step_retries/test_surface_spec.rb` (`type: :reactor`, using only `test_reactor`, `failing_at`, `mock_step`, `have_retried_step`, `be_failure`, `be_success`):
256
+ - (a) `test_reactor(R, inputs).failing_at(:charge)` where `:charge` is a class step with `retries max_attempts: 3, base_delay: 0` → `be_failure` and `have_retried_step(:charge).times(2)`;
257
+ - (b) `mock_step(:charge) { |_a, ctx| ctx.retry_context.attempts_for_step(:charge) < 2 ? RubyReactor.Failure("x") : RubyReactor.Success(1) }` → `be_success` and `have_retried_step(:charge).times(1)`.
258
+ - [X] T046 [US7] In `StepConfig` (`lib/ruby_reactor/dsl/step_builder.rb`), add `retry_source`: `:step_block` if `@retry_config`, else `:step_class` if `impl.respond_to?(:retry_config) && impl.retry_config`, else `:none`. Run T044/T045. Green.
259
+
260
+ ---
261
+
262
+ ## Phase 10: Demo (Constitution VI)
263
+
264
+ **Purpose**: a runnable proof through the public DSL (FR-018).
265
+
266
+ - [X] T047 [P] Create `demo_app/app/reactors/step_retry_demo_reactor.rb` defining, in one file:
267
+ - `StepRetryDemoLog` (a class-level attempt log with `reset!`);
268
+ - `FlakyChargeStep < RubyReactor::Step` with `input :fail_times, :integer` and `retries max_attempts: 3, backoff: :fixed, base_delay: 0.05`, which fails until the attempt counter reaches `fail_times` (log each attempt);
269
+ - `ReserveStockStep < RubyReactor::Step` with a `compensate` that records `StepRetryDemoLog.compensated = true`;
270
+ - `NotifyStep < RubyReactor::Step` with no `retries`, which fails when `fail_notify` is true.
271
+
272
+ `StepRetryDemoReactor < RubyReactor::Reactor` has inputs `fail_times` and `fail_notify` and steps `reserve_stock` → `charge` → `notify`, with no `retries` lines anywhere in the reactor. Model the file layout on `demo_app/app/reactors/step_lock_demo_reactor.rb`.
273
+ - [X] T048 Add `desc "StepRetryDemoReactor — retries declared on the STEP class; succeed-after-retry, exhaust-and-compensate, undeclared step runs once"` and `task step_retry: [:environment, :flush_redis]` to `demo_app/lib/tasks/demo_reactors.rake`. Touch `StepRetryDemoReactor` first (Zeitwerk note as in the `step_lock` task, ≈ line 685). It prints three scenarios:
274
+ 1. `fail_times: 2` → `attempts=3 success?=true`;
275
+ 2. `fail_times: 5` → `success?=false attempts=3 compensated=true`;
276
+ 3. `fail_times: 0, fail_notify: true` → `notify attempts=1 success?=false`.
277
+ - [X] T049 [P] Create `demo_app/spec/reactors/step_retry_demo_reactor_spec.rb` (`type: :reactor`, `require "rails_helper"`) using only the shipped surface. It covers the three scenarios with `be_success`/`be_failure`, `have_retried_step(:charge).times(2)`, and `expect(reactor).not_to have_retried_step(:notify)`. The compensation assertion reads `StepRetryDemoLog.compensated` (application state, not reactor internals).
278
+ - [X] T050 Run the demo end to end in an isolated compose project: `docker compose -p rr-retry run --rm demo-app bin/rails demo:step_retry` and `docker compose -p rr-retry run --rm demo-app bundle exec rspec spec/reactors/step_retry_demo_reactor_spec.rb`. Confirm the printed outcomes match T048.
279
+
280
+ ---
281
+
282
+ ## Phase 11: Polish & Cross-Cutting Concerns
283
+
284
+ - [X] T051 [P] In `documentation/retry_configuration.md`, add "### Declaring retries on a step class (preferred)" as the first example under "Basic Retry Configuration", with `ChargeCard` using `with_lock`, `input` and `retries` together (as in `contracts/dsl-surface.md` §2). Keep the inline example after it. Add:
285
+ - "### Where a step's policy comes from": the step block, then the step class, then none (runs once);
286
+ - "### One declaration per step": the conflict error and the subclassing recipe;
287
+ - "### Direct calls run once": `ChargeCard.run(args)` never retries, because only a reactor coordinates retries; contrast this with locks, which a direct call does take;
288
+ - validation rules for `max_attempts`, `backoff` and `base_delay`.
289
+ - [X] T052 [P] In `documentation/core_concepts.md` line 108 (the class-step lifecycle paragraph that says direct calls are coordinated like reactor calls), append: "Retries are not: a direct call runs once — only a reactor retries a step (see [Retry Configuration](retry_configuration.md#direct-calls-run-once))." In the retry section (≈ 345), lead with the step-class form.
290
+ - [X] T053 [P] In `README.md`:
291
+ - Features bullet (line 24): "**Retries**: per-step retry policies (declared on the step class or step block) with exponential, linear, or fixed backoff.";
292
+ - in "Defining Steps" (≈ line 245, the class-step example), add a `retries max_attempts: 3` line to the example step class with a one-line comment;
293
+ - Retry Configuration blurb (line 1486): mention the step-class form.
294
+ - [X] T054 [P] Mirror T051–T052 into `demo_app/documentation/retry_configuration.md` and `demo_app/documentation/core_concepts.md`.
295
+ - [X] T055 [P] Update `llms.txt` / `llms-full.txt` if they describe the retry DSL (`grep -n "retries" llms*.txt`), so they show the step-class form.
296
+ - [X] T056 Final gate: `bundle exec rspec`, `bundle exec rubocop`, and T050 are all green. Walk through `quickstart.md` Phase B. Commit as `feat: declare retries on step classes`.
297
+
298
+ ---
299
+
300
+ ## Dependencies & Execution Order
301
+
302
+ ### Phase Dependencies
303
+
304
+ - **Phase 1 (Setup)**: none.
305
+ - **Phase 2 (US1, removal)**: after Setup. **Must be committed (T022) before anything else
306
+ starts** (spec FR-017).
307
+ - **Phase 3 (Foundational)**: after T022. Blocks US2–US7.
308
+ - **Phase 4 (US2)**: after Phase 3. T032 is the change the class policy needs to take effect.
309
+ - **Phases 5–9 (US3–US7)**: after Phase 4 (they all run class steps through a reactor, so they
310
+ need T032). They are independent of each other after that.
311
+ - **Phase 10 (Demo)**: after Phase 4. Its spec uses matchers exercised in US7 but needs no US7
312
+ code.
313
+ - **Phase 11 (Polish)**: after the stories it documents. T056 goes last.
314
+
315
+ ### User Story Dependencies
316
+
317
+ ```text
318
+ US1 (removal) ──commit──▶ Foundational ──▶ US2 ──┬──▶ US3
319
+ ├──▶ US4
320
+ ├──▶ US5
321
+ ├──▶ US6
322
+ ├──▶ US7
323
+ └──▶ Demo ──▶ Polish
324
+ ```
325
+
326
+ ### Within Each Phase
327
+
328
+ - Spec tasks are written first and must fail before the implementation tasks of that phase.
329
+ - `lib/ruby_reactor/dsl/step_builder.rb` is touched by T006, T026, T032, T038 and T046, in that
330
+ order, so those tasks are never [P] with each other.
331
+
332
+ ### Parallel Opportunities
333
+
334
+ - **US1**: T004 ∥ T003 (different files); T007 ∥ T008 ∥ T009 ∥ T010 after T006; T012 ∥ T013
335
+ ∥ T014; T016–T021 all in parallel.
336
+ - **Foundational**: T023 ∥ T024; T027 ∥ T028 after T024.
337
+ - **After US2**: the spec files T035, T037, T040, T042, T044 and T045 are all separate files
338
+ and can be written in parallel. The demo files T047 and T049 can run in parallel with
339
+ those.
340
+ - **Polish**: T051–T055 are separate files.
341
+
342
+ ---
343
+
344
+ ## Parallel Example: after Phase 4
345
+
346
+ ```bash
347
+ Task: "T035 step_block_parity_spec.rb (US3)"
348
+ Task: "T037 conflict_spec.rb (US4)"
349
+ Task: "T040 execution_paths_spec.rb (US5)"
350
+ Task: "T042 inheritance_spec.rb (US6)"
351
+ Task: "T044 introspection_spec.rb + T045 test_surface_spec.rb (US7)"
352
+ Task: "T047 step_retry_demo_reactor.rb + T049 its spec (Demo)"
353
+ ```
354
+
355
+ ---
356
+
357
+ ## Implementation Strategy
358
+
359
+ ### MVP = US1 (removal), shipped alone
360
+
361
+ 1. Phase 1 → Phase 2.
362
+ 2. **STOP**: T022 gate, `feat!` commit. This is releasable on its own and already makes
363
+ retries predictable (a step's policy is always on the step).
364
+
365
+ ### Then the core feature
366
+
367
+ 3. Phase 3 → Phase 4 (US2): step classes carry their policy. Validate independently with
368
+ T030/T031.
369
+ 4. US4 (conflict) next: it is the correctness guard.
370
+ 5. Then US3, US5, US6, US7 in any order or in parallel.
371
+ 6. Demo → Polish → T056 `feat:` commit.
372
+
373
+ ---
374
+
375
+ ## Notes
376
+
377
+ - Keep `lib/ruby_reactor/rspec/test_subject.rb` free of retry special cases. Mocks keep
378
+ `impl`, so the class policy follows automatically (research R8).
379
+ - Do not add a policy object or a reactor-level fallback of any kind (research R3, spec
380
+ FR-006).
381
+ - Spec quirk: specs sharing test Redis under load can flake (the async parked-wait spec and
382
+ the 1s Redis ping). Rerun a failing file alone before debugging.
@@ -0,0 +1,250 @@
1
+ # Future improvements for review
2
+
3
+ ## Guard
4
+
5
+ I want to add another hook feature to the step: `guard`
6
+ A guard concept is to execute after validations and before run.
7
+ example:
8
+
9
+ ```ruby
10
+ class SendEmail < RubyReactor::Step
11
+ input :email, :string, format?: /\A[^@\s]+@[^@\s]+\z/
12
+
13
+ def guard
14
+ fail!("Prevent spamming") if EmailService.sent_today?(inputs[:email])
15
+ success! # optional
16
+ end
17
+
18
+ def run
19
+ # do the work
20
+ end
21
+ end
22
+ ```
23
+ ## Fenced context writes
24
+
25
+ **Status:** proposal, not scheduled. Follows from 005 (step coordination remediation), R-18 and
26
+ R-19 in `specs/005-step-coordination-remediation/research.md`.
27
+
28
+ ### The rule
29
+
30
+ A context is written by one process only: the controlled execution that owns it.
31
+
32
+ - Async children (`async_step`, `async_reactor`, map elements) never write their parent. The
33
+ parent holds only the link written at dispatch; each child keeps its own record, and the
34
+ dashboard rebuilds its view from the links. 005 made this true for `async_step` (R-18).
35
+ - Today the rule is a convention: any code with a storage adapter can `SET` any context. This
36
+ proposal makes it a guarantee that the storage enforces.
37
+
38
+ ### Why a convention is not enough
39
+
40
+ Holding the lock at some point is not the same as owning the data when the write lands:
41
+
42
+ | Failure | What happens today |
43
+ |---|---|
44
+ | **Paused holder**: a worker holding the `async:<root id>` lock stalls (GC, network, a long rollback wait) past `context_lock_ttl`. The lock expires, a redelivery takes it and makes progress, then the first worker wakes. | The first worker's next `store_context` is a plain `SET`. It overwrites the newer state. Its auto-extender fails silently. Nothing stops the write. |
45
+ | **Stale read**: `Worker#perform` loads the context *before* `resume_execution` takes the context lock. | A previous holder can write between that read and the lock. The new holder then runs on the older snapshot and saves over the newer one. |
46
+ | **Writer outside any execution**: a path writes with no lock at all. | It overwrites whatever the live execution saved. 005 fixed two of these (`StepWorker#save_root`, the map collector's post-resume save); others remain (below). |
47
+
48
+ The first two are the textbook reasons distributed locks need fencing. A lock that only *excludes*
49
+ is not enough. The storage must also *reject writes from anyone who no longer holds the lock*.
50
+
51
+ ### Remaining writers outside a controlled execution
52
+
53
+ Found while auditing for 005 R-19:
54
+
55
+ | Writer | Where | Today |
56
+ |---|---|---|
57
+ | `Worker.record_retries_exhausted` | backend's retries-exhausted hook | Plain read-modify-write, no lock. Marks the context failed even if a redelivery is live. |
58
+ | `Worker#escalate_snooze` | after the executor returned | Runs after the executor released the context lock. |
59
+ | `Worker#handle_deserialization_failure` | before any executor | Writes a failed payload with no lock. |
60
+ | Map collector, failure branch | `Map::Helpers#resume_parent_execution` | Holds `map_collect:<map_id>`, never the parent's `async:` lock. |
61
+ | `Reactor.cancel`, `Reactor.undo` | any process (app code, console) | `find` → mutate → `save_context`: a blind write racing a live worker. |
62
+ | `Reactor#continue` (interrupt resume) | web request / app code | Writes the payload before `resume_execution` takes the lock. |
63
+ | Synchronous `Reactor.run` / `Executor#execute` | caller's process | Never takes the context lock, so its saves are unfenced. |
64
+
65
+ Writers that already belong to the owning execution:
66
+ - `Executor#save_context` and `#checkpoint!` on the worker path;
67
+ - `RetryManager#requeue_job`;
68
+ - `StepExecutor#checkpoint_root!`;
69
+ - `MapStep#prepare_async_execution`.
70
+
71
+ Two writers only *create* a row that nothing has read yet:
72
+ - `Reactor#save_context` at enqueue;
73
+ - `AsyncReactorStep#save`, the child's first row.
74
+
75
+ ### Proposal
76
+
77
+ Four parts. Parts 1 and 2 are the mechanism; parts 3 and 4 route the remaining writers through it.
78
+
79
+ #### 1. Ownership-checked, versioned writes (the fence)
80
+
81
+ - Every context row gets a version: a small key next to the blob,
82
+ `reactor:<Class>:context:<id>:v`, with the same TTL as the blob.
83
+ - Every write goes through one Lua script. The script takes:
84
+ - the lock key it claims to hold (`lock:async:<root id>`, or the map element's
85
+ `lock:map_element:<map>:<index>`);
86
+ - the owner token of that lock: the per-execution UUID `Executor#acquire_context_lock`
87
+ already generates;
88
+ - the version the writer loaded.
89
+ - The script writes only if **the lock is held by that token** *and* **the stored version equals
90
+ the loaded one**. It then bumps the version.
91
+ - It returns `ok`, `lost_lock` or `stale`:
92
+
93
+ ```lua
94
+ -- KEYS: context_key, version_key, lock_key ARGV: blob, ttl, owner, expected_version
95
+ if redis.call('hget', KEYS[3], 'owner') ~= ARGV[3] then return 'lost_lock' end
96
+ local current = tonumber(redis.call('get', KEYS[2]) or '0')
97
+ if current ~= tonumber(ARGV[4]) then return 'stale' end
98
+ redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2])
99
+ redis.call('set', KEYS[2], current + 1, 'EX', ARGV[2])
100
+ return 'ok'
101
+ ```
102
+
103
+ Why both checks:
104
+ - The **owner check** stops a paused holder from writing at all, even before anyone else has
105
+ written.
106
+ - The **version check** catches a stale read by the current holder, and any path this proposal
107
+ missed.
108
+
109
+ The lock and the data live in the same Redis, and the check runs atomically inside it, so the
110
+ owner comparison does the job of a fencing token. There are no clock or ordering assumptions.
111
+ If locks and contexts ever move to different stores, switch to classic monotonic fencing tokens
112
+ (`INCR` on acquire, highest-token-wins at the store).
113
+
114
+ **Creating a row** uses the same script with `expected_version = 0` and no lock check: a
115
+ create-only `SET NX` equivalent. It covers `Reactor#save_context` at enqueue and an
116
+ `async_reactor` child's first row.
117
+
118
+ #### 2. Lock, then load
119
+
120
+ - A worker takes the context lock **before** it reads the context. The restructure:
121
+ `Worker#perform` acquires `async:<root id>`, then retrieves and deserializes, then hands
122
+ the token and the loaded version to `Executor`. Today `resume_execution` acquires the lock
123
+ mid-way.
124
+ - A synchronous `Reactor.run` takes the same lock for its whole run. It is one `SET NX` and a
125
+ release; today it takes none.
126
+ - Composed children already run inside the root's lock and write through the root. They reuse
127
+ the root's token.
128
+
129
+ #### 3. External actors send requests, not writes
130
+
131
+ Operator actions must not write a live context. Make them requests the owning execution
132
+ consumes, the same pattern as async children writing their own records:
133
+
134
+ - **`Reactor.cancel` / `.undo`**:
135
+ - write a `cancel_requested` key, `reactor:<Class>:context:<id>:cancel`;
136
+ - the owning execution checks it at each step boundary and cancels or undoes itself, under its
137
+ own lock;
138
+ - if no execution is live (the context lock is free), `cancel` takes the lock itself and
139
+ applies the request directly: lock, load, fenced write.
140
+ - **`Reactor#continue`**: take the lock first (bounded wait, because it is a user action), load,
141
+ store the payload, resume. If the lock is held, return "busy — retry" instead of racing.
142
+ - **Worker bookkeeping** (`record_retries_exhausted`, `escalate_snooze`, deserialization
143
+ failure):
144
+ - take the context lock with `wait: 0`;
145
+ - if another process holds it, that process owns the outcome, so log and skip;
146
+ - move `escalate_snooze` inside the executor's lock scope instead of after it.
147
+ - **Map collector**: take the parent's `async:` lock for both branches. The success branch passes
148
+ the token into `resume_execution`, which must accept an already-held lock (re-entrant by owner).
149
+
150
+ #### 4. What a rejected write does
151
+
152
+ A write that comes back `lost_lock` or `stale` raises `Error::ContextOwnershipLost`:
153
+ - The executor stops at once. It saves nothing else, runs no further steps, and releases only
154
+ holds it still owns (every release is already owner-checked).
155
+ - It logs one structured line: `event="ruby_reactor.context.ownership_lost"`, with the context
156
+ id, the lock key and the expected and actual version.
157
+ - The worker does **not** snooze or retry the job. The current owner is responsible for the run.
158
+ - A rejection is a correctness signal, not an error to hide. Expose it in the log and in the
159
+ `:failed_reactor` middleware event, but never mark the context failed: its owner decides the
160
+ outcome.
161
+
162
+ ### Constraints and open questions
163
+
164
+ - **Redis Cluster**: the script touches three keys, which must share a hash slot. Tag them with
165
+ the root id: `lock:async:{<root>}`, `reactor:<Class>:context:{<root>}`, and the version key. The
166
+ ordered lock already does this. Changing the key format needs a migration or a dual-read window.
167
+ - **Composed children's own rows**: `Executor#save_context` of a composed child also stores the
168
+ child under its own id. That is a second key in a different slot. Either tag it with the root
169
+ id too, or drop these rows. They are an observability path: the root blob already embeds the
170
+ child.
171
+ - **Inline test mode**: `Sidekiq::Testing.inline!` re-enters the worker inside a frame that holds
172
+ the lock, which is why `acquire_context_lock` is skipped there today. The fence needs the same
173
+ exemption, or re-entrancy by owner token.
174
+ - **Redis failover**: replication is asynchronous, so a failover can lose an acknowledged lock
175
+ or write. This proposal gives single-primary guarantees, the same as today's locks. Stronger
176
+ guarantees mean `WAIT`/`WAITAOF` on the lock write, or a different store. Document; don't solve
177
+ here.
178
+ - **Other records**: Step Result Records and map element results have the same shape:
179
+ - the dispatcher creates the record;
180
+ - the unit's job updates it under its liveness lock;
181
+ - it could be fenced the same way.
182
+
183
+ Nothing races there today, because the liveness lock drops concurrent duplicates. Decide
184
+ whether to fence them now or later.
185
+
186
+ ### Rollout
187
+
188
+ - **SemVer**: MINOR, behind `config.fenced_context_writes` (default `false`) for one release.
189
+ Existing contexts have no version key, which is read as `0`.
190
+ - A rolling deploy mixes old workers (plain `SET`, no version bump) with new ones, so the new
191
+ workers would see spurious `stale` results. Enable the flag only after every worker runs the new
192
+ version. Flip the default in the next MAJOR.
193
+ - **Cost**: one `EVAL` in place of one `SET` per save; checkpoints already save once per step.
194
+ One extra `SET NX` and release per synchronous run.
195
+
196
+ ### Test plan
197
+
198
+ All against real Redis, per the constitution.
199
+
200
+ 1. **Paused holder**:
201
+ - take the lock with a short TTL and let it expire;
202
+ - a second owner takes it and writes;
203
+ - the first owner's write returns `lost_lock`, and the stored blob is the second owner's.
204
+ 2. **Stale read**:
205
+ - load at version N;
206
+ - another writer (holding the lock through a handover) bumps the version to N+1;
207
+ - the write returns `stale`.
208
+ 3. **Lock, then load**: a redelivery that races a finishing holder always runs on the holder's
209
+ final state. This is the same repro shape as 005 P4.
210
+ 4. **One spec per writer** in the tables above, showing it goes through the fence, or takes the
211
+ lock and skips when it is held.
212
+ 5. **Cancel of a live run**: the request is consumed at the next step boundary. The running
213
+ execution's saves are never overwritten by the canceller.
214
+ 6. **Cluster slot co-location**, if Redis Cluster is supported: every key a script touches
215
+ hashes to one slot.
216
+
217
+ ### Size
218
+
219
+ - Storage adapter: one new script and a versioned `retrieve_context`.
220
+ - Executor and `Worker`: lock, then load, and pass the token.
221
+ - Six writer paths rerouted, plus the cancel/undo request key.
222
+ - Docs: `locks_and_semaphores.md`, `background_and_async.md`, and the README's "Durability &
223
+ Recovery" section.
224
+
225
+ ## Fan-out map inside a composed child
226
+
227
+ **Status:** bug, already present on main (0.8.1). Raised by the PR #56 review.
228
+
229
+ A root that composes a child, where the child runs a `fan_out` map, never finishes. The root
230
+ stays `running`.
231
+
232
+ - `MapStep#prepare_async_execution` stores the child under its own id.
233
+ - The map collector loads that child blob. Its `root_context` is nil, so the collector resumes
234
+ the child as a standalone execution. On a park, it requeues the child the same way.
235
+ - The child completes in its own blob. Nothing resumes the root, and the root's embedded copy of
236
+ the child stays at the map step.
237
+
238
+ To reproduce: `Root` composes `Child`, and `Child` runs a `map ... fan_out batch_size: 1`. Drain
239
+ the element, collector and `Worker` jobs. The root is still `running`.
240
+
241
+ A literal patch would have the collector write the root's blob. That breaks the single-writer
242
+ rule, because the collector holds only `map_collect:`, never the root's `async:` lock.
243
+
244
+ Direction:
245
+ - The collector records the map's completion on the map's own records, which it already does.
246
+ - The collector requeues the ROOT worker and does not resume the child itself.
247
+ - On re-entry, `MapStep` adopts the finished map: collect the results, don't dispatch again.
248
+
249
+ Spec: compose → fan-out map, with and without a park after the map. The root completes either
250
+ way.