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,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,147 @@
1
+ # Execution Flow & Compensation Analysis
2
+
3
+ Research into how RubyReactor orders forward execution and rollback across every construct, under
4
+ locks, retries and failures. Its purpose is to judge whether compensation is **predictable** and
5
+ whether the DSL makes it **visible**. Documentation only: no library, test-suite or demo-app change.
6
+
7
+ ## Scope & baseline
8
+
9
+ - **Code**: commit `faf90e8d` (ruby_reactor 0.8.3, with step-scoped retries #61 and inputs
10
+ protection #63).
11
+ - **Constructs**: plain steps, `compose`, `map` (inline and `fan_out`, `fail_fast` on/off),
12
+ `async_step`, `async_reactor`, `background` reactors, interrupts, manual `cancel`/`undo`.
13
+ - **Conditions**: reactor- and step-level locks/semaphores (ordered lock, rate limit and period by
14
+ reading), retries (inline, worker, element, unit), and every failure kind in
15
+ [execution-order.md §1](execution-order.md#failure-kinds--path).
16
+ - **Evidence**: 63 probe scenarios run against real Redis through the real worker bodies
17
+ (Sidekiq fake mode + drain; `inline!` only where labelled). **64/64 match** the sequences quoted
18
+ in this report. Re-run: [../quickstart.md](../quickstart.md).
19
+ - **Out of scope**: the ActiveJob backend is not probed separately (its adapters delegate to the
20
+ same shared bodies). Neither are rate limits and periods beyond their "never re-taken for rollback"
21
+ rule, the dashboard, or OpenTelemetry spans.
22
+ - **Documentation**: README.md and `./documentation` are **audited, not edited**
23
+ ([findings-and-options.md §2](findings-and-options.md#2-documentation-audit)). Writing current
24
+ behavior in as contract before the follow-up decision would lock in behavior that may be
25
+ unintended. Each remedy updates the docs it affects.
26
+
27
+ ## How to read
28
+
29
+ - *compensate* = the failing step's own cleanup; *undo* = rollback of a completed step;
30
+ *rollback* = both; *left in place* = completed work nothing rolls back; *unit* = an
31
+ `async_step`/`async_reactor` dispatch.
32
+ - Evidence labels: `[R: file:line]` read in source · `[O: S-…]` observed, see
33
+ [../evidence/output.txt](../evidence/output.txt) · `[T: spec:line]` covered by an existing spec.
34
+ - Invariant status: HOLDS · VIOLATED · CONDITIONAL · UNDETERMINED.
35
+ Finding severity: High · Medium · Low
36
+ ([findings-and-options.md](findings-and-options.md) header).
37
+
38
+ ---
39
+
40
+ ## Answers
41
+
42
+ ### Q1 · Are already-executed map elements compensated individually?
43
+
44
+ **No.** Only the element that **fails** is rolled back, individually, by its own reactor.
45
+ Elements that already **succeeded** are never compensated or undone. That holds whether the map
46
+ itself fails or a later step fails.
47
+
48
+ | Situation | Failed element | Elements that succeeded | Evidence |
49
+ |---|---|---|---|
50
+ | Inline, `fail_fast` (default), element k fails | compensated + its steps undone | **left in place**: elements before k. Elements after k never run | [O: S-map-01] |
51
+ | Fan-out, `fail_fast`, element k fails | same, in its own job | **left in place**: whichever elements started before the failure. The set depends on **job scheduling** | [O: S-map-04] [O: S-map-04b] |
52
+ | `fail_fast false` (inline or fan-out) | rolled back individually | kept. The map **succeeds** and the consumer inspects the results | [O: S-map-02] [O: S-map-05] |
53
+ | Map completed, a **later step** fails | — | **all left in place** | [O: S-map-03] [O: S-map-06] |
54
+ | Map inside a compose child / compose inside an element | same rules, nested | same | [O: S-map-07] [O: S-map-08] |
55
+
56
+ Why: `MapStep#compensate` is a stub (`# TODO: Implement compensation for map steps` → `Success()`)
57
+ [R: lib/ruby_reactor/step/map_step.rb:29-31], the map has no `undo`
58
+ [R: lib/ruby_reactor/step.rb:57], and the DSL offers no hook
59
+ [R: lib/ruby_reactor/dsl/map_builder.rb:130-131]. `rollback_failures` stays empty, because nothing
60
+ is attempted. → [F-01](findings-and-options.md#f-01--high--map-elements-that-already-succeeded-are-never-rolled-back),
61
+ [F-05](findings-and-options.md#f-05--medium--fan-out-fail-fast-leaves-a-scheduling-dependent-set-of-elements-in-place),
62
+ INV-19/20/22 in [invariants.md](invariants.md#d-map). No existing spec covers it.
63
+
64
+ ### Q2 · When a composed reactor fails, are previously completed composed reactors compensated?
65
+
66
+ **Yes.** Composition is fully wired into rollback, at any depth and in worker mode:
67
+
68
+ 1. The failing child rolls **itself** back first (its compensate, then its own undos).
69
+ 2. The parent compensates the compose step (a no-op by then), then undoes its own completed steps
70
+ newest-first. **Each earlier compose is undone by replaying its child's undo stack in reverse.**
71
+
72
+ ```text
73
+ compose x(x1 → x2) → compose y(y1 → y2 ✗)
74
+ run:x.x1 run:x.x2 run:y.y1 run:y.y2 compensate:y.y2 undo:y.y1 undo:x.x2 undo:x.x1 ⇒ failure(y)
75
+ ```
76
+
77
+ [O: S-compose-03] · also [O: S-compose-01] [O: S-compose-02] [O: S-compose-04] [O: S-compose-06] ·
78
+ [T: spec/compose_spec.rb:192, :197] · INV-15–18.
79
+
80
+ **Except** in three cases:
81
+
82
+ - `compose` with `retries`: a retried child **resumes** with its already-undone steps counted as
83
+ done, so they are not re-run and their stale results flow on
84
+ ([F-02](findings-and-options.md#f-02--high--compose-with-retries-resumes-a-child-whose-earlier-steps-were-already-undone),
85
+ [O: S-compose-05]).
86
+ - A child's `Halt` does **not** stop the parent. The parent continues with `nil`
87
+ ([F-07](findings-and-options.md#f-07--medium--halt-means-different-things-at-different-nesting-levels),
88
+ [O: S-compose-08]).
89
+ - A `map` or async unit **inside** a child keeps its own rules (Q1, and
90
+ [F-04](findings-and-options.md#f-04--high--an-async_steps-own-compensate--undo-never-run-the-docs-say-they-do)/[F-09](findings-and-options.md#f-09--medium--dispatched-units-run-after-their-dispatcher-rolled-back-including-units-of-a-failed-map-element)).
91
+
92
+ ### Q3 · Would `compensate_all` / `compensate_each` on `map` close a real gap?
93
+
94
+ **Yes, the gap is real**: INV-19 and INV-20 are VIOLATED and have no spec coverage. Both proposed
95
+ shapes would close it, and they are complementary rather than alternatives: per-element vs bulk is
96
+ a matter of how the author wants to clean up. Before either works, three things must be settled:
97
+
98
+ 1. **Two moments, not one.** Cleanup is needed when the map **fails** (the library's
99
+ *compensate*) and when a **later step** fails after the map completed (the library's *undo*).
100
+ `compensate_*` as named covers only the first. The hooks should be named for both moments, or one
101
+ block should be documented to cover both.
102
+ 2. **Plumbing.** In fan-out mode the map step must be on the parent's undo stack (today it never is,
103
+ [R: lib/ruby_reactor/map/helpers.rb:97]). Raw per-element results must also stay available even
104
+ when a `collect` block transformed them.
105
+ 3. **In-flight fan-out elements.** Elements still running when the hook fires finish afterwards and
106
+ escape it, unless the collector waits for them
107
+ ([O-05-a](findings-and-options.md#options-for-f-05-scheduling-dependent-fan-out-leftovers)).
108
+
109
+ There is also a third shape: implicit **element-undo replay**, which makes `map` behave like
110
+ `compose` and needs no new DSL. It is the most consistent option, but the only breaking one.
111
+ Full comparison on fail_fast, inline/fan-out, retries, later failures and data availability:
112
+ [O-01 evaluation](findings-and-options.md#o-01-evaluation-compensate_all-vs-compensate_each).
113
+
114
+ ---
115
+
116
+ ## Top findings
117
+
118
+ **High**
119
+
120
+ - [F-01](findings-and-options.md#f-01--high--map-elements-that-already-succeeded-are-never-rolled-back): map elements that succeeded are never rolled back (map failure or later failure).
121
+ - [F-02](findings-and-options.md#f-02--high--compose-with-retries-resumes-a-child-whose-earlier-steps-were-already-undone): `compose` + `retries` resumes a child whose earlier steps were already undone.
122
+ - [F-03](findings-and-options.md#f-03--high--some-failures-skip-rollback-entirely): argument-resolution errors and non-`StandardError` exceptions skip rollback entirely.
123
+ - [F-04](findings-and-options.md#f-04--high--an-async_steps-own-compensate--undo-never-run-the-docs-say-they-do): an `async_step`'s own `compensate`/`undo` never run. The docs say they do.
124
+
125
+ **Medium**: F-05 scheduling-dependent fan-out leftovers · F-06 raising `where`/`guard`
126
+ compensates a never-run step · F-07 nested `Halt` inconsistency · F-08 `Reactor.undo` outside the
127
+ reactor lock · F-09 units run after their dispatcher's rollback (also escaping a failed map
128
+ element) · F-10 composite rollback coverage is asymmetric and invisible in the DSL.
129
+
130
+ **Low**: F-11 to F-16 (lock gap, async retry telemetry, missing failure attribution, dead collect
131
+ default, lock event key asymmetry, interrupt status docs).
132
+
133
+ What reliably **holds**: compensate-then-reverse-undo ordering (DAG included). Rollback failures
134
+ never stop the rollback and are always reported. Retries always finish before compensation, which
135
+ runs exactly once. Never-started steps are not compensated (except F-06). Composition rollback is
136
+ full, at any depth. Worker runs match inline order. Reactor locks are held through rollback.
137
+ Step locks are re-taken for it. A crash re-drives from the last checkpoint.
138
+ 29 of 41 invariants hold ([invariants.md](invariants.md#coverage-summary)).
139
+
140
+ ## Files
141
+
142
+ | File | Answers |
143
+ |---|---|
144
+ | [execution-order.md](execution-order.md) | Rollback algorithm, failure-kind table, construct lifecycles, full order matrix, lock and retry cross-sections |
145
+ | [invariants.md](invariants.md) | 41 invariants with status, evidence and existing-spec coverage |
146
+ | [findings-and-options.md](findings-and-options.md) | 16 ranked findings, documentation audit, improvement options (proposals) |
147
+ | [../evidence/](../evidence/) | Probe harness, 7 probe files, `output.txt` transcript |