ruby_reactor 0.8.4 → 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 (72) 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 +196 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +47 -11
  7. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  8. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
  9. data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
  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/step_builder.rb +91 -19
  13. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  14. data/lib/ruby_reactor/error/rescuable.rb +28 -0
  15. data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
  16. data/lib/ruby_reactor/executor/result_handler.rb +18 -16
  17. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  18. data/lib/ruby_reactor/executor/step_executor.rb +59 -49
  19. data/lib/ruby_reactor/executor.rb +38 -4
  20. data/lib/ruby_reactor/map/collector.rb +21 -11
  21. data/lib/ruby_reactor/map/dispatcher.rb +29 -3
  22. data/lib/ruby_reactor/map/element_executor.rb +9 -3
  23. data/lib/ruby_reactor/map/helpers.rb +32 -2
  24. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  25. data/lib/ruby_reactor/reactor.rb +24 -0
  26. data/lib/ruby_reactor/rspec/matchers.rb +19 -3
  27. data/lib/ruby_reactor/step/compose_step.rb +7 -1
  28. data/lib/ruby_reactor/step/map_step.rb +109 -4
  29. data/lib/ruby_reactor/step.rb +7 -0
  30. data/lib/ruby_reactor/step_worker.rb +46 -22
  31. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  32. data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
  33. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  34. data/lib/ruby_reactor/version.rb +1 -1
  35. data/lib/ruby_reactor/web/api.rb +1 -1
  36. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
  37. data/lib/ruby_reactor/web/public/index.html +1 -1
  38. data/lib/ruby_reactor/worker.rb +3 -1
  39. data/lib/ruby_reactor.rb +17 -6
  40. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  41. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  42. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  43. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  44. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  45. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  46. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  47. data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
  48. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  49. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  50. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  51. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
  52. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  53. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  54. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  55. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  56. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  57. data/specs/007-execution-flow-analysis/plan.md +127 -0
  58. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  59. data/specs/007-execution-flow-analysis/research.md +202 -0
  60. data/specs/007-execution-flow-analysis/spec.md +270 -0
  61. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  62. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  63. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  64. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  65. data/specs/008-rollback-reliability/data-model.md +139 -0
  66. data/specs/008-rollback-reliability/plan.md +233 -0
  67. data/specs/008-rollback-reliability/quickstart.md +105 -0
  68. data/specs/008-rollback-reliability/research.md +653 -0
  69. data/specs/008-rollback-reliability/spec.md +561 -0
  70. data/specs/008-rollback-reliability/tasks.md +1110 -0
  71. data/specs/future_improvements.md +48 -0
  72. metadata +35 -2
@@ -0,0 +1,1110 @@
1
+ ---
2
+
3
+ description: "Task list for 008 Reliable Rollback Across Constructs"
4
+ ---
5
+
6
+ # Tasks: Reliable Rollback Across Constructs
7
+
8
+ **Input**: Design documents from `specs/008-rollback-reliability/`
9
+
10
+ **Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md),
11
+ [data-model.md](data-model.md), [contracts/](contracts/), [quickstart.md](quickstart.md)
12
+
13
+ **Tests**: REQUIRED. Constitution III (test-first, real Redis) and spec FR-027. In every story,
14
+ write the spec tasks first and confirm they FAIL on the current code before implementing.
15
+
16
+ **Organization**: tasks are grouped by user story (spec.md US1–US6). Phases 1–8 (T001–T079) are
17
+ the first implementation, done. Phases 9–13 (T080–T121) are the 2026-09-27 revision after the PR #65
18
+ review (research R-14–R-18). Research decisions are cited
19
+ as `R-nn`, data-model sections as `DM §n`, contracts as `RS §n` (rollback-semantics.md) and
20
+ `API §n` (api-surface.md).
21
+
22
+ ## Format: `[ID] [P?] [Story] Description`
23
+
24
+ - **[P]**: can run in parallel (different files, no dependency on an incomplete task)
25
+ - **[Story]**: US1–US6 from spec.md
26
+
27
+ ## Path Conventions
28
+
29
+ Single gem project: `lib/ruby_reactor/`, `spec/`, `demo_app/`, `gui/`, `documentation/`.
30
+
31
+ **Test rules**:
32
+
33
+ - Run specs against the test Redis at `redis://localhost:6780`.
34
+ - Async paths use `for_each_async_backend` from `spec/support/async_backends.rb` plus
35
+ `drain_async_jobs`. Never use `Sidekiq::Testing.inline!` (Constitution III).
36
+ - Don't run the gem suite and the demo suite at the same time: they flush the same Redis.
37
+
38
+ ---
39
+
40
+ ## Phase 1: Setup (Shared Infrastructure)
41
+
42
+ **Purpose**: test scaffolding shared by every story's specs.
43
+
44
+ - [X] T001 Create `spec/support/rollback_recorder.rb`:
45
+ - Module `RollbackRecorder` with `.log` (Array of strings), `.reset!` and `.record(event)`.
46
+ - An `RSpec.configure` hook that calls `RollbackRecorder.reset!` before each example under
47
+ `spec/ruby_reactor/rollback/`.
48
+ - A reactor base class `RollbackRecorder::Reactor < RubyReactor::Reactor` with a class macro
49
+ `recording_step(name, after: nil, fail: nil, idx: false, undo_fails: false, compensate_raises: false)`.
50
+ Port it from the `pstep` macro in `specs/007-execution-flow-analysis/evidence/harness.rb`.
51
+ Each recording step logs `run:<tag>.<name>[<i>]`, `compensate:…` and `undo:…` exactly like the
52
+ 007 probes, so assertions can compare whole sequences such as
53
+ `%w[run:a run:e.e1[0] … undo:a]`.
54
+ - [X] T002 [P] Add `config.filter_run_excluding :slow` to `spec/spec_helper.rb`, so `:slow` examples
55
+ run only with `--tag slow`.
56
+ - [X] T003 [P] Baseline check, with no file changes:
57
+ - Run `bundle exec ruby specs/007-execution-flow-analysis/evidence/run.rb`.
58
+ - Confirm the tail line reads `63 scenarios, 63 match, 0 mismatch`, and that
59
+ `git diff specs/007-execution-flow-analysis/evidence/output.txt` is empty after re-teeing.
60
+
61
+ ---
62
+
63
+ ## Phase 2: Foundational (Blocking Prerequisites), R-01 bounded refactor
64
+
65
+ **Purpose**: `StepConfig` becomes the single owner of per-step lifecycle operations. This is a
66
+ **pure refactor with no behavior change**. Every story builds on it.
67
+
68
+ **⚠️ CRITICAL**: no user story work can start until T009 is green.
69
+
70
+ - [X] T004 Add `StepConfig#resolve_arguments(context)` in `lib/ruby_reactor/dsl/step_builder.rb`.
71
+ The body is exactly `StepExecutor#resolve_arguments` today: for each
72
+ `arguments[name] = { source:, transform: }`, `value = source.resolve(context)`, then apply
73
+ `transform.call(value)` if a transform is present. It returns a Hash. Nothing is wrapped yet
74
+ (US3 adds that). `InterruptStepConfig` inherits it.
75
+ - [X] T005 In `lib/ruby_reactor/executor/step_executor.rb`, replace every call to the private
76
+ `resolve_arguments(step_config)` with `step_config.resolve_arguments(@context)`, and delete the
77
+ private method. Run `grep -rn "resolve_arguments(" lib/` and switch any other caller that
78
+ resolves a step config's arguments (e.g. in `executor/async_step_dispatch.rb`).
79
+ - [X] T006 [P] In `lib/ruby_reactor/step_worker.rb`, replace `resolve_arguments(step_config, context)`
80
+ with `step_config.resolve_arguments(context)` and delete the private method (~line 359).
81
+ - [X] T007 Add `StepConfig#call_compensate(error, arguments, context)` and
82
+ `StepConfig#call_undo(result_value, arguments, context)` in `lib/ruby_reactor/dsl/step_builder.rb`,
83
+ each wrapped in `catch(StepSignals::TAG)`. Dispatch order is identical to
84
+ `CompensationManager#compensate_step`/`#undo_step` today:
85
+ - inline block: `compensate_block.call(error, wrap_inputs(arguments), context)` /
86
+ `undo_block.call(result_value, wrap_inputs(arguments), context)`
87
+ - else `impl.compensate(error, arguments, context)` / `impl.undo(result_value, arguments, context)`
88
+ when `has_impl?`
89
+ - else `RubyReactor.Skipped()`
90
+ - [X] T008 Update `lib/ruby_reactor/executor/compensation_manager.rb`:
91
+ - `compensate_step` and `undo_step` call `step_config.call_compensate` / `call_undo` inside the
92
+ existing `coordinated_rollback` block. Trace, middleware and `record_rollback_failure` are
93
+ unchanged.
94
+ - Wrap `compensate_step`'s body in `@context.with_step(step_config.name) { … }`, as
95
+ `rollback_completed_steps` already does for undo, so constructs can read `context.current_step`
96
+ during compensate (R-02).
97
+ - Add public `def compensate(step_config, error, arguments) = compensate_step(step_config, error, arguments)`.
98
+ `StepWorker` uses it in US4.
99
+ - [X] T009 Run `bundle exec rspec` and `bundle exec rubocop`. Both must be green with **no spec
100
+ edits**: the refactor changes no behavior.
101
+
102
+ **Checkpoint**: the foundation is ready and the user stories can proceed (in parallel if staffed).
103
+
104
+ ---
105
+
106
+ ## Phase 3: User Story 1 — Succeeded map elements are rolled back (Priority: P1) 🎯 MVP
107
+
108
+ **Goal**: a map rolls back every completed element, both when the map fails (compensate) and when
109
+ a later step fails or the run is undone manually (undo). This holds in inline and fan-out modes,
110
+ and a fail-fast fan-out settles before it rolls back (R-02, R-03, R-04; F-01, F-05).
111
+
112
+ **Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/map_rollback_spec.rb spec/ruby_reactor/rollback/map_fan_out_settle_spec.rb`.
113
+ The sequences must match RS §3 rows S-map-01/03/04/04b/06/07/08.
114
+
115
+ ### Tests for User Story 1 ⚠️ write first, confirm they FAIL
116
+
117
+ - [X] T010 [P] [US1] Create `spec/ruby_reactor/rollback/map_rollback_spec.rb` (inline mode, using
118
+ `RollbackRecorder`). Examples:
119
+ - (a) S-map-01: fail-fast with element 2 failing gives exactly the RS §3 sequence, ending
120
+ `undo:e.e2[1] undo:e.e1[1] undo:e.e2[0] undo:e.e1[0] undo:a`, and `failure.step_name == :m`.
121
+ - (b) S-map-03: all elements ok and `b` fails. Every element is undone, highest index first,
122
+ before `undo:a`.
123
+ - (c) `fail_fast false`, element 2 fails, then `b` fails. Elements 0, 1 and 3 are undone.
124
+ Element 2's steps appear exactly once in the undo sequence (its own self-rollback).
125
+ - (d) A `collect` block that raises after all elements succeeded. Every element is undone
126
+ (FR-007).
127
+ - (e) S-map-07: map inside a compose.
128
+ - (f) S-map-08: a compose inside the element. Nested steps unwind innermost-first.
129
+ - (g) `Reactor.undo(result.execution_id)` after a completed map undoes every element. A second
130
+ `Reactor.undo` records no new undo events (idempotent).
131
+ - (h) An element step whose `undo` returns `Failure` for element 1 only. The final failure's
132
+ `rollback_failures` contains an entry with `map_step: :m, element_index: 1`, and elements 0, 2
133
+ and 3 are still undone (FR-005).
134
+ - (i) Delete element 1's context row from storage between the map's success and `b`'s failure.
135
+ An entry with `map_step: :m, reason: :context_unavailable` appears (its `element_index` is
136
+ `nil`, since the index lives in the deleted row), and elements 0, 2 and 3 are still undone.
137
+ - (j) An empty source succeeds, and a later failure raises no error from the map's undo.
138
+ - (k) US1-8: an existing element reactor whose steps declare `undo` needs no map-level
139
+ declaration for those undos to run.
140
+ - [X] T011 [P] [US1] Create `spec/ruby_reactor/rollback/map_fan_out_settle_spec.rb`
141
+ (`for_each_async_backend`, `fan_out(true)`, drain). Examples:
142
+ - (a) S-map-04: jobs drained in index order give the RS §3 sequence, and element 3 is skipped.
143
+ - (b) S-map-04b: jobs performed in order 3, 2, 1, 0. Reorder the fake queue the same way
144
+ `specs/007-execution-flow-analysis/evidence/harness.rb` does. Element 3 is undone and elements
145
+ 1 and 0 never run.
146
+ - (c) S-map-06: all elements ok and `b` fails in the collector-resumed parent. Every element is
147
+ undone, then `a` (R-03).
148
+ - (d) SC-003: 100 iterations with `Random.new(seed)`-shuffled element job order. After each run,
149
+ every element context with status `completed` has an empty `undo_stack`, and `RollbackRecorder`
150
+ shows an `undo:` for every `run:` of a completed element.
151
+ - (e) `batch_size 2` over 6 elements, element 1 fails. Indices 2–5 hold `{"_skipped"=>true}`
152
+ slots, the map counter is `0`, and the collector resolves the failure exactly once.
153
+ - (f) After (e), `RubyReactor::Map::Sweeper.run_once` returns `redispatched: 0`.
154
+ - (g) Hold `RubyReactor::Lock.new("map_element:<map_id>:0", owner: "x", ttl: 30, wait: 0)`
155
+ while the map rollback runs. The result has `reason: :element_in_flight, element_index: 0`, and
156
+ the other elements are undone.
157
+ - [X] T012 [P] [US1] Create `spec/ruby_reactor/rollback/map_scale_spec.rb`, tagged `:slow`
158
+ (SC-006). An inline map over 10,000 trivial elements whose `collect` raises: all 10,000 elements
159
+ are undone, there is no `ContextTooLargeError`, and the parent's serialized context size is
160
+ within 2× of the same reactor with 10 elements.
161
+
162
+ ### Implementation for User Story 1
163
+
164
+ - [X] T013 [US1] Implement map rollback in `lib/ruby_reactor/step/map_step.rb` (R-02, DM §5–§6):
165
+ - Replace the stub with `def compensate` and add `alias undo compensate`.
166
+ - `step_name = context.current_step`; `map_id = "#{context.context_id}:#{step_name}"`.
167
+ - Element class: `context.reactor_class.steps[step_name].arguments[:mapped_reactor_class][:source].value`.
168
+ - Ids: `storage.retrieve_map_element_context_ids(map_id, context.reactor_class.name).uniq`.
169
+ - For each id, load `storage.retrieve_context(id, RubyReactor.reactor_storage_name(element_class))`:
170
+ - missing → a
171
+ `{ step: step_name, kind: :undo, reason: :context_unavailable, map_step: step_name, element_index: nil, message: "element context #{id} expired" }`
172
+ entry. The index is stored only in the row itself.
173
+ - otherwise `Context.deserialize_from_retry`, and keep it only when `status.to_s == "completed"`.
174
+ - Sort the kept elements by `map_metadata[:index]` (string or symbol key) descending.
175
+ - Per element:
176
+ - Acquire `RubyReactor::Lock.new("map_element:#{map_id}:#{index}", owner: SecureRandom.uuid, ttl: RubyReactor.configuration.context_lock_ttl, wait: 0)`.
177
+ Skip the lock when `Map::ElementExecutor.inline_testing_mode?`. On
178
+ `Lock::AcquisitionError`, record `reason: :element_in_flight`.
179
+ - Otherwise run `ex = Executor.new(element_class, {}, element_ctx); ex.undo_all; ex.save_context`,
180
+ then tag each of `ex.compensation_manager.rollback_failures` with `map_step: step_name, element_index: index`.
181
+ - Release the lock in `ensure`.
182
+ - Return `RubyReactor.Success()` when no entries were collected, else
183
+ `RubyReactor.Failure("map :#{step_name} rollback incomplete", rollback_failures: entries)`, the
184
+ same shape as `ComposeStep#compensate`.
185
+ - Mark the serial loop with a `# ponytail:` comment naming its ceiling (linear in the number of
186
+ elements) and the upgrade path (rollback fan-out).
187
+ - [X] T014 [US1] In `lib/ruby_reactor/map/helpers.rb` `resume_parent_execution`, success branch:
188
+ before `resume_parked_aware`, push
189
+ `{ step: parent_context.reactor_class.steps[step_name_sym], arguments: {}, result: RubyReactor.Success(nil) }`
190
+ onto `parent_context.undo_stack` (R-03, DM §4). Add a comment on why the record is empty:
191
+ `MapStep#undo` reads the element index, and the record stays constant-size.
192
+ - [X] T015 [P] [US1] Add `decrement_map_counter_by(map_id, amount, reactor_class_name)` to
193
+ `lib/ruby_reactor/storage/redis_adapter.rb`: `DECRBY` on `map_counter_key`, refresh
194
+ `durability_ttl`, return the new value.
195
+ - [X] T016 [US1] In `lib/ruby_reactor/map/element_executor.rb` `check_fail_fast?`, before
196
+ `finalize_execution`, write `storage.store_map_result(map_id, arguments[:index], { "_skipped" => true }, parent_reactor_class_name, strict_ordering: arguments[:strict_ordering])`
197
+ (R-04 §1).
198
+ - [X] T017 [US1] In `lib/ruby_reactor/map/dispatcher.rb` `dispatch_batch`, fail-fast branch
199
+ (~line 69). When the failed-context marker is set, do not simply `return`:
200
+ - Read the total from `storage.retrieve_map_metadata(map_id, reactor_class_name)["count"]`.
201
+ - Claim the rest with `new_offset = storage.increment_map_offset(map_id, total, reactor_class_name)`,
202
+ so `claimed = (new_offset - total)...[new_offset, total].min` is empty for a later dispatcher.
203
+ - Write a `_skipped` slot for each claimed index.
204
+ - `left = storage.decrement_map_counter_by(map_id, claimed.size, reactor_class_name)` if any were
205
+ claimed.
206
+ - If `left <= 0`, enqueue `perform_map_collection_async` with the same arguments
207
+ `ElementExecutor.finalize_execution` uses.
208
+ - Depends on T015.
209
+ - [X] T018 [US1] In `lib/ruby_reactor/map/collector.rb` `perform_collection`, compute
210
+ `results_count` before the fail-fast branch and change it to
211
+ `if (failed_context_id = …) then return if results_count < total_count; handle_failure(…); return; end`
212
+ (R-04 §2). Comment: the failure is applied only once every index has settled, so the map's
213
+ compensate sees every completed element.
214
+ - [X] T019 [P] [US1] In `lib/ruby_reactor/map/result_enumerator.rb`, make `each` (and therefore
215
+ `successes`/`failures`/`count` if derived from it) skip slots that are
216
+ `Hash` with key `"_skipped"`, and make `[](index)` return `nil` for one. Add examples to the
217
+ existing enumerator spec (`grep -rln ResultEnumerator spec/`).
218
+ - [X] T020 [US1] Run the new specs plus `spec/map/`, `spec/ruby_reactor/map/`,
219
+ `spec/single_worker_map_spec.rb` and `spec/compose_spec.rb`. Fix only expectations that asserted
220
+ element effects stay in place, and add a comment on each changed expectation citing F-01.
221
+
222
+ ### Documentation & demo for User Story 1
223
+
224
+ - [X] T021 [P] [US1] Update `documentation/data_pipelines.md` (~line 167 and the fail-fast section):
225
+ - Completed elements are rolled back when the map fails and when a later step fails, by replaying
226
+ each element's own step `undo`s, highest index first.
227
+ - Fail-fast fan-out waits for elements in flight before it reports failure.
228
+ - `context_ttl` is the rollback horizon (`context_unavailable`).
229
+ - Element `undo`s should be idempotent.
230
+ - `fail_fast false`: failed elements roll back individually, successes are undone on a later
231
+ failure.
232
+ - [X] T022 [US1] Update `README.md`:
233
+ - Map and Compensation sections, lines ~1309 and ~1398: add maps to "undoes completed steps", and
234
+ list the new `rollback_failures` keys `map_step`/`element_index` and the reasons
235
+ `context_unavailable`/`element_in_flight`.
236
+ - Map example text: no map-level rollback DSL is needed.
237
+ - [X] T023 [P] [US1] Add to `CHANGELOG.md` under Unreleased → Features (BREAKING): "map rolls back
238
+ completed elements". Include a migration note: element-step `undo` blocks now run on map failure
239
+ and on later failures, so make them idempotent.
240
+ - [X] T024 [P] [US1] Create `demo_app/app/reactors/map_refund_demo_reactor.rb`. It contains:
241
+ - `MapRefundChargeStep < RubyReactor::Step`, whose `run` charges and whose `undo` refunds. Both
242
+ append to a class-level `charges`/`refunds` Array with `reset!`. It fails for a configured
243
+ `fail_order_id`.
244
+ - An element reactor (`MapRefundElementReactor`).
245
+ - `MapRefundDemoReactor`, with inputs `orders` and an optional `fail_after_map` flag that makes a
246
+ final `notify` step fail.
247
+ - [X] T025 [US1] Add `demo:map_rollback` to `demo_app/lib/tasks/demo_reactors.rake` (`desc` plus
248
+ `[:environment, :flush_redis]`). It runs both scenarios (an element fails; the step after the map
249
+ fails) and prints charges and refunds.
250
+ - [X] T026 [P] [US1] Create `demo_app/spec/reactors/map_refund_demo_reactor_spec.rb`
251
+ (`type: :reactor`, `test_reactor`, `be_failure`, `have_rollback_failure`). It asserts the refunds
252
+ match the charges in both scenarios. Use only `lib/ruby_reactor/rspec.rb` helpers.
253
+
254
+ **Checkpoint**: US1 is complete and independently testable. This is the MVP.
255
+
256
+ ---
257
+
258
+ ## Phase 4: User Story 2 — A retried composed reactor re-runs rolled-back work (Priority: P1)
259
+
260
+ **Goal**: a compose retry after a failed attempt starts a fresh child, while a park/resume still
261
+ resumes (R-05, F-02, DM §7).
262
+
263
+ **Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/compose_retry_spec.rb`, matching
264
+ RS §3 rows S-compose-05 and 05b.
265
+
266
+ ### Tests for User Story 2 ⚠️ write first, confirm they FAIL
267
+
268
+ - [X] T027 [P] [US2] Create `spec/ruby_reactor/rollback/compose_retry_spec.rb`. Examples:
269
+ - (a) S-compose-05: `compose :child` with `retries max_attempts: 2`, where child `c2` fails on the
270
+ first attempt only, gives `… retry … run:child.c1 run:child.c2`. The result value is from
271
+ attempt 2.
272
+ - (b) S-compose-05b: the same, plus a parent step `b` that fails, gives
273
+ `… run:b compensate:b undo:child.c2 undo:child.c1`.
274
+ - (c) The child's inner step `c2` declares `retries max_attempts: 2` and fails twice per compose
275
+ attempt. It is attempted twice in **each** compose attempt (the retry budget resets with the
276
+ fresh child).
277
+ - (d) The parent `execution_trace` has one `type: :compose_attempt_discarded` entry with
278
+ `step: :child` and the old `child_context_id`. When attempt 1's `c1` undo returns `Failure`,
279
+ that entry's `rollback_failures` lists it (DM §7).
280
+ - (e) Worker path: `background all: true`, drain. The retry requeue still re-runs `c1`.
281
+ - (f) Park is not retry: model on `spec/ruby_reactor/step_coordination/park_spec.rb` ("a parent
282
+ lock across a composed park (R4)"). A child that parks on contention after `c1` completed
283
+ resumes without logging a second `run:child.c1`.
284
+
285
+ ### Implementation for User Story 2
286
+
287
+ - [X] T028 [US2] In `lib/ruby_reactor/step/compose_step.rb` `run`, right after reading
288
+ `composed_data`:
289
+ - If `composed_data&.dig(:context)&.status.to_s == "failed"`, append
290
+ `{ type: :compose_attempt_discarded, step: step_name, child_context_id: old.context_id, rollback_failures: (old.failure_reason.respond_to?(:rollback_failures) ? old.failure_reason.rollback_failures : []), timestamp: Time.now }`
291
+ to `context.execution_trace`, and set `composed_data = nil`. `prepare_child_context` then builds
292
+ a fresh child, and `execute_child_reactor` calls `execute`, not resume.
293
+ - Add a comment explaining why a failed child is a previous attempt (it already rolled itself
294
+ back), while any other status is a park/resume (FR-011).
295
+ - [X] T029 [US2] Run `spec/compose_spec.rb`, `spec/nested_reactor_inline_execution_spec.rb`,
296
+ `spec/ruby_reactor/step_coordination/park_spec.rb` and
297
+ `spec/ruby_reactor/step_coordination/rollback_under_contention_spec.rb`. All must be green.
298
+
299
+ ### Documentation & demo for User Story 2
300
+
301
+ - [X] T030 [P] [US2] Update `documentation/composition.md` (~line 184 and the Compensation table
302
+ ~195): `retries` on a compose retry the **whole** child from a fresh start, child steps without
303
+ `undo` run again, and the discarded attempt stays visible in the trace.
304
+ - [X] T031 [P] [US2] Add to `CHANGELOG.md` under Bug Fixes: "compose retries re-run the whole child
305
+ instead of resuming a rolled-back one". Note that child steps without `undo` now run again on
306
+ retry.
307
+ - [X] T032 [P] [US2] Create `demo_app/app/reactors/compose_retry_demo_reactor.rb`. A child reactor
308
+ runs `reserve` (with `undo`) and then `confirm`, which fails on the first call only (a class-level
309
+ counter with `reset!`). The parent composes it with `retries max_attempts: 2`.
310
+ - [X] T033 [US2] Add `demo:compose_retry` to `demo_app/lib/tasks/demo_reactors.rake`. It prints that
311
+ `reserve` ran twice and the final result.
312
+ - [X] T034 [P] [US2] Create `demo_app/spec/reactors/compose_retry_demo_reactor_spec.rb`
313
+ (`test_reactor`, `be_success`, `have_run_step`/`have_retried_step`). It asserts `reserve` ran on
314
+ both attempts.
315
+
316
+ **Checkpoint**: US2 works independently of US1.
317
+
318
+ ---
319
+
320
+ ## Phase 5: User Story 3 — Every failure after completed work rolls back (Priority: P1)
321
+
322
+ **Goal**:
323
+
324
+ - Argument-resolution and condition errors become attributed never-started failures.
325
+ - Unknown standard errors roll back.
326
+ - Failures from a compensation that failed carry the step name.
327
+ - Process-level exceptions mark an inline run `aborted`.
328
+
329
+ (R-06, R-07, R-08; F-03, F-06, F-13; DM §2, §3, §9; API §2–§4)
330
+
331
+ **Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/failure_rollback_spec.rb spec/ruby_reactor/rollback/aborted_execution_spec.rb`,
332
+ matching RS §3 rows S-plain-07, S-edge-03 and S-edge-04.
333
+
334
+ ### Tests for User Story 3 ⚠️ write first, confirm they FAIL
335
+
336
+ - [X] T035 [P] [US3] Create `spec/ruby_reactor/rollback/failure_rollback_spec.rb`. Examples:
337
+ - (a) S-plain-07: `b`'s argument `transform:` raises `ArgumentError`. The sequence is
338
+ `run:a undo:a`, with `failure.step_name == :b`, `failure.reactor_name` set,
339
+ `failure.exception_class == "ArgumentError"`, and no `compensate:b`.
340
+ - (b) The same when `b`'s argument source is `result(:a, path)` and the path raises.
341
+ - (c) S-edge-04: `where` raises. The sequence is `run:a undo:a`, with no `compensate:b`.
342
+ - (d) The same for `guard`.
343
+ - (e) `b` has `retries max_attempts: 3` and its `where` raises. It is attempted once (not
344
+ retried).
345
+ - (f) A middleware whose `complete_step` hook raises `RuntimeError` for step `a` (a StandardError
346
+ outside any body). Completed steps are undone and the failure has `reactor_name` (FR-016).
347
+ - (g) `b` fails and its `compensate` raises. `failure.step_name == :b` (CompensationError
348
+ attribution, FR-017).
349
+ - (h) Worker path: `background before: :b`, where `b`'s transform raises, then drain. `undo:a`
350
+ is logged and the failure is attributed to `b`.
351
+ - (i) An `async_step :u` whose argument transform raises, then drain. The unit record's
352
+ failure has `step_name: :u` and `retryable: false`, and its `compensate` block was not called.
353
+ - [X] T036 [P] [US3] Create `spec/ruby_reactor/rollback/aborted_execution_spec.rb` with
354
+ `class AbortCrash < Exception; end`. Examples:
355
+ - (a) S-edge-03: `a → b` where `b` raises `AbortCrash`. `Reactor.run` re-raises the **same**
356
+ object (`raise_error(AbortCrash) { |e| expect(e).to equal(crash) }`), and no `undo:a` is logged.
357
+ - (b) The stored context (`storage.retrieve_context`) has status `"aborted"` and a non-empty
358
+ `undo_stack`.
359
+ - (c) `RubyReactor::Sweeper.run_once` enqueues nothing for it.
360
+ - (d) `RubyReactor::Reactor.undo(id)` (via the reactor class) logs `undo:a` and the status
361
+ becomes `"cancelled"`.
362
+ - (e) A composed child raising `AbortCrash` marks both the child and the root `aborted`.
363
+ - (f) An executor whose context has `inline_async_execution = true` leaves the status `running`
364
+ (the worker path is unchanged).
365
+ - (g) `RubyReactor::Worker` given an aborted context id does not resume it.
366
+
367
+ ### Implementation for User Story 3
368
+
369
+ - [X] T037 [P] [US3] Create `lib/ruby_reactor/error/argument_resolution_error.rb`: `class ArgumentResolutionError < Base`
370
+ with `attr_reader :exception_class`,
371
+ `initialize(message, step:, original_error:, context: nil)` setting
372
+ `@exception_class = original_error.class.name`, and `def retryable? = false` (DM §2).
373
+ - [X] T038 [P] [US3] Create `lib/ruby_reactor/error/condition_error.rb`: `class ConditionError < Base`,
374
+ with the same shape as T037.
375
+ - [X] T039 [US3] In `lib/ruby_reactor/dsl/step_builder.rb`:
376
+ - `resolve_arguments` rescues `Error::ExecutionParked` (re-raise unchanged). Any other
377
+ `StandardError => e` raises
378
+ `Error::ArgumentResolutionError.new("Step '#{name}' could not resolve its arguments: #{e.message}", step: name, original_error: e)`,
379
+ with `set_backtrace(e.backtrace)`.
380
+ - `should_run?` wraps a raising condition or guard the same way into `Error::ConditionError`.
381
+ - [X] T040 [US3] In `lib/ruby_reactor/executor/compensation_manager.rb`, add
382
+ `RubyReactor::Error::ArgumentResolutionError` and `RubyReactor::Error::ConditionError` to
383
+ `NEVER_STARTED_ERROR_CLASSES`, and update the constant's comment.
384
+ - [X] T041 [US3] In `lib/ruby_reactor/executor/step_executor.rb`:
385
+ - `execute_step`: move argument resolution inside the existing `begin` so `:start_step`
386
+ (with `{}`) and `:failed_step` fire. On `Error::ArgumentResolutionError => e`, return
387
+ `@result_handler.handle_step_result(step_config, RubyReactor::Failure(e, step_name: step_config.name, reactor_name: @reactor_class.name, inputs: @context.inputs, redact_inputs: <same as safe_execute_step_sync>, step_arguments: {}, retryable: false, exception_class: e.exception_class), {})`.
388
+ - `safe_execute_step_sync`: add
389
+ `rescue Error::ArgumentResolutionError, Error::ConditionError => e` **before**
390
+ `rescue StandardError`, building the same `Failure(…, retryable: false, exception_class: e.exception_class)`.
391
+ `RetryManager` then does not retry it, and `handle_step_failure` sees a never-started error.
392
+ - [X] T042 [US3] In `lib/ruby_reactor/executor/result_handler.rb` `build_execution_failure`:
393
+ - The `Error::Base` branch adds `step_name: error.step` and
394
+ `reactor_name: @context.reactor_class&.name`.
395
+ - The `else` branch calls `@compensation_manager.rollback_completed_steps`, returns
396
+ `RubyReactor.Failure("Execution failed: #{error.message}", exception_class: error.class.name, step_name: @context.current_step, reactor_name: @context.reactor_class&.name)`,
397
+ and replaces the "don't rollback" comment with the R-07 rationale.
398
+ - `CompensationError` gets `step_name` through the `Error::Base` branch.
399
+ - [X] T043 [US3] In `lib/ruby_reactor/step_worker.rb` `perform_unit`: add
400
+ `rescue Error::ArgumentResolutionError, Error::ConditionError => e` before `rescue StandardError`.
401
+ Log `failed`, then `complete(RubyReactor.Failure(e, step_name: @step_name, reactor_name: @reactor_class_name, retryable: false, exception_class: e.exception_class), context)`.
402
+ - [X] T044 [US3] In `lib/ruby_reactor/executor.rb`, in both `execute` and `resume_execution`, add
403
+ after `rescue StandardError`:
404
+ `rescue Exception # rubocop:disable Lint/RescueException` →
405
+ `@context.status = :aborted unless @context.inline_async_execution; raise`.
406
+ Comment it with R-08: no rollback code runs on process-level exceptions, the caller-process run
407
+ is recorded as aborted for a manual undo, and a worker run is redelivered. The existing `ensure`
408
+ persists it.
409
+ - [X] T045 [P] [US3] Add `"aborted"` to `TERMINAL_STATUSES` in `lib/ruby_reactor/worker.rb`, so a
410
+ worker never resumes an aborted run forward.
411
+ - [X] T046 [P] [US3] Add `aborted` to the known-status lists in
412
+ `lib/ruby_reactor/storage/redis_reactor_scan.rb` (`determine_status`, ~line 70) and
413
+ `lib/ruby_reactor/web/api.rb` (~line 176).
414
+ - [X] T047 [P] [US3] Dashboard, in `gui/src/lib/reactors.ts`:
415
+ - Add `'aborted'` to the `errors` status group.
416
+ - `gui/src/components/StatusBadge.tsx`: add an `aborted` style and icon, distinct from `failed`.
417
+ - `gui/src/components/ReactorClassInstances.tsx`: add `<option value="aborted">Aborted</option>`.
418
+ - Add a case to `gui/src/lib/__tests__/reactors.test.ts`.
419
+ - Run `npm --prefix gui test`, then `bundle exec rake build:ui` to refresh
420
+ `lib/ruby_reactor/web/public/`.
421
+ - [X] T048 [US3] Run the US3 specs, then `spec/ruby_reactor/error_handling_spec.rb`,
422
+ `failure_reporting_spec.rb`, `compensation_failure_spec.rb`, `validations_spec.rb`,
423
+ `sweeper_spec.rb` and `undo_spec.rb`. Update only expectations that pinned the old unattributed
424
+ or no-rollback shapes, with a comment citing F-03/F-13.
425
+
426
+ ### Documentation & demo for User Story 3
427
+
428
+ - [X] T049 [P] [US3] Update `documentation/locks_and_semaphores.md` (~777-778) so the never-started
429
+ list includes argument-resolution and condition errors. Update `documentation/interrupts.md`
430
+ (~155-157) to describe the `aborted` status and that `Reactor.undo(id)` rolls an aborted run back.
431
+ - [X] T050 [US3] Update `README.md` lines ~16 and ~25 (the compensation promise): every standard
432
+ error after completed work rolls back, and a process-level exception marks the run `aborted` for
433
+ a manual undo. Document the `ArgumentResolutionError`/`ConditionError` classes in the errors
434
+ section.
435
+ - [X] T051 [P] [US3] Add to `CHANGELOG.md`:
436
+ - Bug Fixes: argument, condition and unknown errors roll back and carry `step_name`; a
437
+ compensation failure carries `step_name`.
438
+ - Features: the `aborted` status.
439
+ - [X] T052 [P] [US3] Create `demo_app/app/reactors/argument_failure_demo_reactor.rb`: `reserve`
440
+ (with `undo`) then `charge`, whose argument `transform:` raises for a configured input.
441
+ - [X] T053 [US3] Add `demo:failure_rollback` to `demo_app/lib/tasks/demo_reactors.rake`. It prints
442
+ the undo and the failure's `step_name`.
443
+ - [X] T054 [P] [US3] Create `demo_app/spec/reactors/argument_failure_demo_reactor_spec.rb`
444
+ (`test_reactor`, `be_failure`). It asserts that `reserve` was undone and that the failure names
445
+ `charge`.
446
+
447
+ **Checkpoint**: US3 works independently.
448
+
449
+ ---
450
+
451
+ ## Phase 6: User Story 4 — An async step's rollback hooks run as declared (Priority: P2)
452
+
453
+ **Goal**:
454
+
455
+ - A unit compensates itself once, in its own job, after its final attempt fails.
456
+ - An inline `undo` on `async_step` is rejected.
457
+ - A class `undo` is warned about.
458
+
459
+ (R-09; F-04; DM §8; API §1, §5, §6)
460
+
461
+ **Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/async_step_compensate_spec.rb spec/ruby_reactor/dsl/async_step_spec.rb`,
462
+ matching RS §3 rows S-async-02 and S-async-07.
463
+
464
+ ### Tests for User Story 4 ⚠️ write first, confirm they FAIL
465
+
466
+ - [X] T055 [P] [US4] Create `spec/ruby_reactor/rollback/async_step_compensate_spec.rb`
467
+ (`for_each_async_backend`, drain). Examples:
468
+ - (a) S-async-07: `async_step :u` with `retries max_attempts: 3`, always failing, no reader.
469
+ The sequence is `run:u ×3 compensate:u`, `compensate:u` is logged exactly once, and the unit
470
+ record has `compensation.status == "completed"`.
471
+ - (b) Fails once then succeeds: no `compensate:u` and no `compensation` key.
472
+ - (c) S-async-02: reader `r` returns `Failure`. The sequence contains `compensate:u` once,
473
+ `compensate:r` and `undo:a`.
474
+ - (d) Invalid arguments (`validate_args`) or a raising transform: no `compensate:u`.
475
+ - (e) The body returns `Halt`: no compensate.
476
+ - (f) `compensate` raises. The record has `compensation.status == "failed"` with one
477
+ `rollback_failures` entry, and the unit record's `result` is still the body failure.
478
+ - (g) The step class declares `with_lock`. The compensate re-takes the unit's lock (assert via a
479
+ `lock_acquired` middleware event during compensation).
480
+ - (h) The parent context is not written by the unit job. Mirror the assertion style in
481
+ `spec/ruby_reactor/async_step_single_writer_spec.rb`.
482
+ - (i) Middleware `start_compensation`/`complete_compensation` fire with step name `:u`.
483
+ - (j) Definition time:
484
+ - `Class.new(RubyReactor::Reactor) { async_step(:u) { run { … }; undo { … } } }` raises
485
+ `RubyReactor::Error::ValidationError`, with a message matching `/async_step :u/` and
486
+ `/compensate/`.
487
+ - A step class overriding `undo` used as `async_step :u, ThatClass` warns once to stderr
488
+ (`expect { … }.to output(/undo.*will not run/).to_stderr`) and does not raise.
489
+ - [X] T056 [US4] Tighten `spec/ruby_reactor/dsl/async_step_spec.rb` (~line 111, "compensates when a
490
+ reader inspects the failure"). Add a `compensate` to the fixture's async step that records into
491
+ the fixture log, and assert it ran exactly once.
492
+
493
+ ### Implementation for User Story 4
494
+
495
+ - [X] T057 [US4] In `lib/ruby_reactor/step_worker.rb` `run_step`, after the retry loop:
496
+ - When `result.is_a?(RubyReactor::Failure)` and the error is neither an
497
+ `Error::InputValidationError` nor in
498
+ `Executor::CompensationManager::NEVER_STARTED_ERROR_CLASSES`, compensate the unit:
499
+ ```ruby
500
+ manager = Executor::CompensationManager.new(context)
501
+ outcome = context.with_step(@step_name) { manager.compensate(step_config, result.error, arguments) }
502
+ ```
503
+ - Set
504
+ `@compensation = { "status" => (outcome.is_a?(RubyReactor::Failure) ? "failed" : (outcome.respond_to?(:skipped?) && outcome.skipped? ? "skipped" : "completed")), "rollback_failures" => ContextSerializer.serialize_value(manager.rollback_failures), "completed_at" => Time.now.iso8601 }`.
505
+ - Merge `"compensation" => @compensation` into the record in `complete` when it is set (via
506
+ `run_fields` or next to it).
507
+ - Never call `save_context`/`store_context` for the parent. Comment it with the single-writer
508
+ rule.
509
+ - [X] T058 [US4] In `lib/ruby_reactor/dsl/step_builder.rb`, extract the dedupe/print part of
510
+ `warn_deprecation` into `warn_definition(site, message)`. It prints
511
+ `"[RubyReactor] #{location} #{reactor_label} #{message}"` once per location, using
512
+ `StepBuilder.deprecation_sites`. `warn_deprecation` keeps its exact current output by calling it
513
+ with its DEPRECATION prefix and suffix.
514
+ - [X] T059 [US4] In `lib/ruby_reactor/dsl/async_macros.rb` `async_step`, after `builder.build`:
515
+ - If `config.undo_block`, raise `RubyReactor::Error::ValidationError` with: "`undo` on async_step
516
+ :#{name} would never run: the parent never undoes an independent async unit. Put
517
+ failure cleanup in the reading step's `compensate`, or use a `step`/`compose`/`map` (tracked
518
+ for undo) or an `async_reactor` child whose steps declare `undo`." Use the wording from API §1.
519
+ - Elsif `impl.is_a?(Class) && impl < RubyReactor::Step && impl.instance_method(:undo).owner != RubyReactor::Step`,
520
+ call `builder.send(:warn_definition, caller_locations(1, 1).first, "async_step :#{name} uses #{impl}; its `undo` will not run for this async use (async units are never undone).")`.
521
+ - [X] T060 [US4] Grep `spec/` and `demo_app/` for `async_step` blocks that declare `undo`
522
+ (`grep -rn -A15 "async_step" spec demo_app/app | grep -n "undo"`). Remove or move them so the
523
+ suite loads. Then run `spec/ruby_reactor/dsl/async_step_spec.rb`,
524
+ `spec/ruby_reactor/async_step_single_writer_spec.rb`, `spec/ruby_reactor/step_contract_async_spec.rb`,
525
+ `spec/async_retry_dsl_spec.rb` and `spec/async_retry_integration_spec.rb`. All must be green.
526
+
527
+ ### Documentation & demo for User Story 4
528
+
529
+ - [X] T061 [P] [US4] Update `documentation/background_and_async.md` (~279-292):
530
+ - The unit's `compensate` runs once in its own job after its final attempt, whether or not it is
531
+ read.
532
+ - `undo` is rejected (inline) or warned (class).
533
+ - A reader surfacing the failure compensates itself and undoes the parent, and never compensates
534
+ the unit twice.
535
+ - [X] T062 [US4] Update `README.md` ~545-550 (the async_step compensation paragraph) to match T061.
536
+ - [X] T063 [P] [US4] Add to `CHANGELOG.md` two BREAKING entries with migration notes (R-12 rows 2–3):
537
+ "`async_step` `compensate` runs in the unit's job on final failure", and "inline `undo` in
538
+ `async_step` raises at definition time".
539
+ - [X] T064 [P] [US4] Create `demo_app/app/reactors/async_step_compensate_demo_reactor.rb`: an
540
+ `async_step :notify` whose body always fails, with `retries max_attempts: 2` and a `compensate`
541
+ that records to a class-level log. Remove any inline `undo` from
542
+ `demo_app/app/reactors/async_step_demo_reactor.rb` if T060 found one.
543
+ - [X] T065 [US4] Add `demo:async_step_compensate` to `demo_app/lib/tasks/demo_reactors.rake`. It
544
+ drains or waits for the unit, then prints the unit record's `compensation`.
545
+ - [X] T066 [P] [US4] Create `demo_app/spec/reactors/async_step_compensate_demo_reactor_spec.rb`.
546
+ If no shipped matcher can assert the unit's compensation:
547
+ - Add `have_compensated_async_step(step_name)` to `lib/ruby_reactor/rspec/matchers.rb`. It reads
548
+ `retrieve_step_result(...)["compensation"]["status"] == "completed"`, with a
549
+ `.because_failed` chain for `"failed"`.
550
+ - Add an example for the matcher in `spec/ruby_reactor/rspec/`.
551
+ - Never hand-roll storage reads in the demo spec (Constitution VI).
552
+
553
+ **Checkpoint**: US4 works independently.
554
+
555
+ ---
556
+
557
+ ## Phase 7: User Story 5 — One rollback rule for every construct (Priority: P3)
558
+
559
+ **Goal**:
560
+
561
+ - The coordinator asks the step whether a success is tracked for undo.
562
+ - The F-10 table, the 007 analysis and the core docs describe one rule plus a per-construct table.
563
+ - The 007 harness confirms that only the intended sequences changed.
564
+
565
+ (R-10; FR-022, FR-023; SC-002, SC-007, SC-008)
566
+
567
+ **Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/rollback_rule_spec.rb`, plus the
568
+ 007 harness printing `63 scenarios, 63 match, 0 mismatch`.
569
+
570
+ ### Tests for User Story 5 ⚠️ write first, confirm they FAIL
571
+
572
+ - [X] T067 [P] [US5] Create `spec/ruby_reactor/rollback/rollback_rule_spec.rb`:
573
+ - `rollback_tracked?` is `true` for `step`, `compose`, `map` and `interrupt` configs, and
574
+ `false` for `async_step` and `async_reactor` configs. This fails until T068.
575
+ - A reactor `a → async_reactor child → b(fails)` still never undoes the child (INV-25 unchanged).
576
+
577
+ ### Implementation for User Story 5
578
+
579
+ - [X] T068 [US5] Add `def rollback_tracked? = !async_dispatch?` to `StepConfig` in
580
+ `lib/ruby_reactor/dsl/step_builder.rb`. In `lib/ruby_reactor/executor/result_handler.rb`
581
+ `handle_success`, push only `if step_config.rollback_tracked?`. Delete `async_unit?` and move its
582
+ comment onto `rollback_tracked?`. Confirm `RubyReactor::Dsl::AsyncReactorBuilder#build` produces a
583
+ `StepConfig` with `async_dispatch` set.
584
+ - [X] T069 [US5] Update the `expected:` sequences of exactly the 14 scenarios in RS §3 in
585
+ `specs/007-execution-flow-analysis/evidence/probes/`: `01_plain.rb` (S-plain-07), `02_compose.rb`
586
+ (S-compose-05, 05b), `03_map.rb` (S-map-01, 03, 04, 04b, 06, 07, 08), `04_async.rb`
587
+ (S-async-02, 07) and `07_interrupts_manual.rb` (S-edge-03, 04).
588
+ - For S-edge-03, add a `note:` printing the stored status (`aborted`).
589
+ - Re-run
590
+ `bundle exec ruby specs/007-execution-flow-analysis/evidence/run.rb | tee specs/007-execution-flow-analysis/evidence/output.txt`
591
+ and require `63 scenarios, 63 match, 0 mismatch`.
592
+ - Depends on US1–US4.
593
+ - [X] T070 [US5] Update `specs/007-execution-flow-analysis/analysis/execution-order.md`:
594
+ - §1 failure-kinds table rows (argument, condition and unknown errors; non-`StandardError` →
595
+ `aborted`).
596
+ - Rule R7 text.
597
+ - §2 construct lifecycles for map (compensate and undo replay, settle), compose (fresh child on
598
+ retry) and async_step (unit-local compensate).
599
+ - Add the R-05 resume-entry-point audit table.
600
+ - Mark changed rows "changed in 008".
601
+ - [X] T071 [P] [US5] Update `specs/007-execution-flow-analysis/analysis/invariants.md` and
602
+ `specs/007-execution-flow-analysis/analysis/findings-and-options.md`:
603
+ - `invariants.md`: INV-06, 07, 13, 19, 20, 22 and 24 become HOLDS with 008 spec coverage cited.
604
+ INV-22 is restated per R-04 (left-in-place set).
605
+ - `findings-and-options.md`: add a "Resolved by 008" line to F-01..F-06 and F-13, and rebuild
606
+ the F-10 table from RS §2.
607
+ - [X] T072 [US5] Update `documentation/core_concepts.md` (~321-331):
608
+ - Add "The rollback rule" (RS §1) and the per-construct table (RS §2).
609
+ - `README.md` line ~35: change "Auto compensation/undo" to cover all constructs, with async units
610
+ independent by design.
611
+ - `documentation/DAG.md` (~228-240): cascading compensation now includes maps.
612
+
613
+ **Checkpoint**: all stories are complete, and the docs describe one rule.
614
+
615
+ ---
616
+
617
+ ## Phase 8: Polish & Cross-Cutting Concerns
618
+
619
+ - [X] T073 Add an aggregate `demo:rollback_reliability` task to
620
+ `demo_app/lib/tasks/demo_reactors.rake`. It depends on `:map_rollback`, `:compose_retry`,
621
+ `:failure_rollback` and `:async_step_compensate`. Add `:rollback_reliability` to the `demo:all`
622
+ prerequisite list.
623
+ - [X] T074 [P] Add a note to `specs/future_improvements.md`: the reactor `Sweeper` re-enqueues
624
+ **in-progress** inline runs (status `running`, no `async:` lock). This is pre-existing, and 008
625
+ only excludes `aborted` runs. Also note a possible later map-level `undo_all` override and a
626
+ rollback fan-out for very large maps (R-02 alternatives).
627
+ - [X] T075 Documentation consistency pass (REQUIRED, Constitution Development Workflow). Check every
628
+ row of the 007 documentation audit (`findings-and-options.md` §2) tied to F-01..F-06 or F-13
629
+ against the new behavior:
630
+ - README.md 16, 25, 35, 545-550, 1309, 1398
631
+ - data_pipelines.md 167
632
+ - composition.md 184, 195
633
+ - background_and_async.md 279-292
634
+ - core_concepts.md 321-331
635
+ - DAG.md 228-240
636
+ - locks_and_semaphores.md 777-778
637
+ - interrupts.md 155-157
638
+
639
+ Each must now read CONFIRMED (SC-007). Fix any stragglers.
640
+ - [X] T076 Consolidate the CHANGELOG. `CHANGELOG.md` Unreleased has one "Migration notes" block
641
+ listing every R-12 breaking row, with a before/after snippet for each.
642
+ - [X] T077 Run `bundle exec rubocop` and `bundle exec rspec` (full suite, alone), then
643
+ `bundle exec rspec spec/ruby_reactor/rollback --tag slow` (SC-006, SC-009).
644
+ - [X] T078 Docker demo acceptance per [quickstart.md](quickstart.md) §5, using an isolated compose
645
+ project (`-p rr_rollback` plus an override with unique `container_name`s and
646
+ `ports: !reset []`). Run `bin/rails demo:rollback_reliability` and
647
+ `bundle exec rspec spec/reactors/map_refund_demo_reactor_spec.rb spec/reactors/compose_retry_demo_reactor_spec.rb spec/reactors/argument_failure_demo_reactor_spec.rb spec/reactors/async_step_compensate_demo_reactor_spec.rb`,
648
+ then the whole demo spec suite (existing map and async demos may change behavior).
649
+ - [X] T079 Run [quickstart.md](quickstart.md) §1–§6 end to end and record the results
650
+ (SC-001..SC-009) in the PR description.
651
+
652
+ ---
653
+
654
+ ## Phase 9: Revision — User Story 2: a nested reactor is never retried as a whole (Priority: P1)
655
+
656
+ **Goal**: `retries` on `compose`/`async_reactor` raises at class definition. The fresh-child code is
657
+ deleted. A child retries its own steps (R-14, FR-009–FR-012, API §1, RS §1 rule 7).
658
+
659
+ **Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/compose_retry_spec.rb spec/ruby_reactor/step_retries`,
660
+ matching RS §3 rows S-compose-05 and S-compose-05b.
661
+
662
+ ### Tests for User Story 2 (revision) ⚠️ write first, confirm they FAIL
663
+
664
+ - [X] T080 [P] [US2] Rewrite `spec/ruby_reactor/rollback/compose_retry_spec.rb`. Change the describe
665
+ text to "a nested reactor is never retried as a whole". Delete the examples "re-runs the whole
666
+ child from a fresh start", "undoes the final attempt's child steps on a later failure", "gives the
667
+ fresh child a fresh retry budget", "keeps the discarded attempt … in the parent's trace" and
668
+ "re-runs the child when the retry is requeued to a worker", with any fixtures only they use. Keep
669
+ "resumes, not retries, a child that parked after c1 completed". Add:
670
+ - (a) `Class.new(RubyReactor::Reactor) { compose(:child, Child) { retries max_attempts: 2 } }`
671
+ raises `RubyReactor::Error::DeprecatedDslError` matching `/:child/` and `/own steps/`.
672
+ - (b) An inline compose block (`compose :child do retries max_attempts: 2; step(:c1) { … } end`)
673
+ raises the same.
674
+ - (c) `async_reactor(:child, Child) { retries max_attempts: 2 }` raises the same.
675
+ - (d) Child `c1 → c2`, where `c2` declares `retries max_attempts: 2, base_delay: 0` and fails on its
676
+ first call only: the recorded sequence is `run:child.c1 run:child.c2 run:child.c2` (c1 once) and
677
+ the result is a success.
678
+ - (e) The same child plus a parent step `b` that fails: `… run:b compensate:b undo:child.c2
679
+ undo:child.c1`, and each `undo:` appears exactly once.
680
+ - (f) `c2` always fails (its retries run out): the child undoes `c1`, the compose fails, the
681
+ parent's earlier step is undone, and `run:child.c1` appears exactly once.
682
+ - [X] T081 [P] [US2] In `spec/ruby_reactor/step_retries/declaration_spec.rb` (~lines 69-84), change
683
+ "validates `retries` in a compose block" and "validates `retries` in an async_reactor block" so
684
+ they expect `RubyReactor::Error::DeprecatedDslError` for any `retries` call, including a valid one.
685
+ Rename them "rejects `retries` in a … block".
686
+
687
+ ### Implementation for User Story 2 (revision)
688
+
689
+ - [X] T082 [US2] In `lib/ruby_reactor/dsl/compose_builder.rb`: remove `include RubyReactor::Dsl::Retryable`,
690
+ `@retry_config = nil` and the `retry_config: @retry_config` build key. Add a `retries(*)` stub
691
+ next to `async(*)` that raises `RubyReactor::Error::DeprecatedDslError.new(message, step: @name)`.
692
+ Message: "`retries` on a `compose` has been removed: a parent never retries a nested reactor as a
693
+ whole. Declare `retries` on the child reactor's own steps (e.g. `step :x do retries max_attempts: 3
694
+ … end`, or `retries` in the step class); the child retries them itself." Add a short comment
695
+ citing 008 R-14.
696
+ - [X] T083 [P] [US2] Do the same in `lib/ruby_reactor/dsl/async_reactor_builder.rb`, with "`retries` on
697
+ an `async_reactor`" in the message.
698
+ - [X] T084 [US2] In `lib/ruby_reactor/step/compose_step.rb`: delete `discard_failed_attempt` and
699
+ `attempt_rollback_failures`, and restore `composed_data = context.composed_contexts[step_name]` in
700
+ `run`. `grep -rn compose_attempt_discarded lib gui/src spec` must return nothing.
701
+ - [X] T085 [US2] Run `spec/ruby_reactor/rollback/compose_retry_spec.rb`,
702
+ `spec/ruby_reactor/step_retries/`, `spec/compose_spec.rb`,
703
+ `spec/nested_reactor_inline_execution_spec.rb` and `spec/ruby_reactor/step_coordination/park_spec.rb`.
704
+ All must be green.
705
+
706
+ ### Documentation & demo for User Story 2 (revision)
707
+
708
+ - [X] T086 [P] [US2] Update `documentation/composition.md`:
709
+ - The inline example (~line 33): move `retries max_attempts: 3` from the compose block into the
710
+ `step :update_bio` block, with the comment "retries belong on the child's steps".
711
+ - Point 5 (~185-194): rewrite as "**No whole-child retries**": a parent never retries a nested
712
+ reactor; `retries` on a `compose` raises at definition time; declare `retries` on the child's
713
+ steps; a parked child is resumed, not re-run. Replace the example to show `retries` inside a
714
+ child step.
715
+ - The compose vs `async_reactor` table (~205): the `retries` row reads "not allowed: declare it on
716
+ the child's own steps" in both columns.
717
+ - [X] T087 [P] [US2] In `demo_app/app/reactors/compose_retry_demo_reactor.rb`, remove `retries` from
718
+ the `compose :reservation` block and declare `retries max_attempts: 2, base_delay: 0` at class level
719
+ in `ComposeRetryConfirmStep`. Update the file's header comment: `confirm` retries inside the child,
720
+ `reserve` runs once.
721
+ - [X] T088 [US2] Update `demo:compose_retry` in `demo_app/lib/tasks/demo_reactors.rake` (~line 284) to
722
+ print that `reserve` ran once and `confirm` twice, then the result.
723
+ - [X] T089 [P] [US2] Update `demo_app/spec/reactors/compose_retry_demo_reactor_spec.rb` (shipped
724
+ matchers only): success, `reserve` logged once, `confirm` called twice.
725
+ - [X] T090 [P] [US2] In `CHANGELOG.md` Unreleased: delete the Bug Fixes entry "A `compose` with
726
+ `retries` re-runs the whole child…" (~line 247) and replace migration note 4 (~153-160) with a
727
+ BREAKING entry: "`retries` on `compose`/`async_reactor` raises
728
+ `RubyReactor::Error::DeprecatedDslError`". Before/after snippet: `compose(:booking,
729
+ BookingReactor) { retries max_attempts: 2 }` → `retries max_attempts: 2` inside the child step
730
+ (or its class) that can fail transiently.
731
+
732
+ **Checkpoint**: US2 revision works on its own.
733
+
734
+ ---
735
+
736
+ ## Phase 10: Revision — User Story 6: one way to skip a step (Priority: P2)
737
+
738
+ **Goal**: `where`/`guard` are removed, and `ConditionError` with them. `Skipped` is documented as
739
+ "this run caused no effect" (R-15, R-17, FR-014, FR-029, API §1).
740
+
741
+ **Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/removed_dsl_spec.rb`, and
742
+ `grep -rn "ConditionError\|should_run?\|@conditions\|@guards" lib` returns nothing.
743
+
744
+ ### Tests for User Story 6 ⚠️ write first, confirm they FAIL
745
+
746
+ - [X] T091 [P] [US6] Create `spec/ruby_reactor/rollback/removed_dsl_spec.rb`:
747
+ - (a) For each of `step :s do … end`, `async_step :s do … end` and `interrupt :s do … end`, and for
748
+ each of `where { true }` and `guard { true }`: defining the reactor raises
749
+ `RubyReactor::Error::DeprecatedDslError` matching `/:s/` and `/Skipped/`.
750
+ - (b) A step whose body returns `Skipped(:v)` lets the next step run and read `:v` through
751
+ `result(:s)`, and a later failure does not undo it (FR-029).
752
+ - [X] T092 [US6] Delete the tests that only exercise conditions, with the fixtures only they use:
753
+ - `spec/ruby_reactor/rollback/failure_rollback_spec.rb`: `WhereRaises`, `GuardRaises`,
754
+ `RetriedWhereRaises` and the three condition examples (~lines 90-110). Update the header comment.
755
+ - `spec/ruby_reactor/step_contract_enforcement_spec.rb`: "does not validate a step whose where is
756
+ false" (~297).
757
+ - `spec/ruby_reactor/step_retries/class_policy_spec.rb`: "makes no attempt when the step is
758
+ skipped by `where`" (~112).
759
+ - `spec/ruby_reactor/step_coordination/lock_spec.rb`: the "a step suppressed by `where` (FR-012)"
760
+ describe (~153), and `GuardedLockedChargeReactor` in `spec/support/reactors/step_coordination_reactors.rb`.
761
+ - `spec/ruby_reactor/step_coordination/single_site_spec.rb`: the "an async_step suppressed by its
762
+ guard" describe (~289), and `GuardedAsyncStep`, `GuardedAsyncReactor` and `ASYNC_GUARD_FLAG`
763
+ in `spec/support/reactors/step_coordination_reactors.rb`.
764
+ - `spec/ruby_reactor/dsl/reactor_background_spec.rb`: "never fires when the named step is skipped
765
+ by a guard" (~118), and `BackgroundSkippedTriggerReactor` in `spec/support/reactors/background_reactors.rb`.
766
+
767
+ ### Implementation for User Story 6
768
+
769
+ - [X] T093 [US6] In `lib/ruby_reactor/dsl/step_builder.rb`:
770
+ - Remove `:conditions, :guards` from the builder's `attr_accessor` and `StepConfig`'s `attr_reader`,
771
+ `@conditions = []`/`@guards` initialization, the `conditions:`/`guards:` build keys and config
772
+ reads, and `StepConfig#should_run?`.
773
+ - Replace `where`/`guard` with `where(*)`/`guard(*)` stubs raising
774
+ `RubyReactor::Error::DeprecatedDslError.new(message, step: @name)`. Message: "`where`/`guard` have
775
+ been removed. To skip :<name>, return `Skipped(value)` (or call `skip!(value)`) from its `run`
776
+ body; the reactor continues with that value." Cite 008 R-15 in a comment.
777
+ - [X] T094 [P] [US6] Remove the `conditions:`/`guards:` keys from `lib/ruby_reactor/dsl/interrupt_builder.rb`,
778
+ `compose_builder.rb`, `map_builder.rb` and `async_reactor_builder.rb`, and from `InterruptStepConfig`
779
+ if it reads them.
780
+ - [X] T095 [US6] In `lib/ruby_reactor/executor/step_executor.rb`: remove the two `should_run?` early
781
+ returns (`execute_step_sync`, `execute_step_sync_without_result_handling`). In `handoff_at?`,
782
+ return `true` after the mode/step/`inline_async_execution` checks, and update its comment (the
783
+ hand-off fires whenever the named step is reached). Drop `Error::ConditionError` from the rescue
784
+ (~237) and fix its comment.
785
+ - [X] T096 [US6] In `lib/ruby_reactor/step_worker.rb`: remove the suppression block at the top of
786
+ `run_step` (~246-255), and drop `Error::ConditionError` from the rescue (~93) and its comment.
787
+ - [X] T097 [US6] Delete `lib/ruby_reactor/error/condition_error.rb` (Zeitwerk loads errors, so there is
788
+ no require to remove). Remove it from `NEVER_STARTED_ERROR_CLASSES` in
789
+ `lib/ruby_reactor/executor/compensation_manager.rb`, and from `never_started_wrapper?` in
790
+ `lib/ruby_reactor/executor/result_handler.rb` (only `ArgumentResolutionError` stays; fix the
791
+ "Argument/condition" comment). `grep -rn "ConditionError\|should_run?" lib spec` must return nothing.
792
+ - [X] T098 [US6] Run `removed_dsl_spec.rb`, `failure_rollback_spec.rb`, `reactor_background_spec.rb`,
793
+ `step_coordination/`, `step_contract_enforcement_spec.rb`, `step_retries/` and every
794
+ `spec/**/*interrupt*_spec.rb`. All must be green.
795
+
796
+ ### Documentation & demo for User Story 6
797
+
798
+ - [X] T099 [P] [US6] Remove `where`/`guard`/`ConditionError` from the docs:
799
+ `documentation/background_and_async.md` (~159, the "skipped by a `where`/`guard`" bullet),
800
+ `documentation/DAG.md` (~248, "or a `where` condition that raises"),
801
+ `documentation/locks_and_semaphores.md` (~780, the `where`/`guard` clause),
802
+ `documentation/core_concepts.md` (Rollback Rule item 2), and `README.md` (~1432).
803
+ - [X] T100 [P] [US6] Document `Skipped` for rollback (R-17, FR-029) in `documentation/core_concepts.md`
804
+ ("Skipping a single step", and Rollback Rule item 5) and `README.md` (~857). Wording: `Skipped`
805
+ means "this run caused no effect for this step", so it is never compensated or undone. A step
806
+ that finds its effect already in place and owned by this workflow (for example, a redelivered run
807
+ whose earlier attempt created it) returns `Success(value)` so its `undo` runs on rollback.
808
+ - [X] T101 [P] [US6] In `CHANGELOG.md` Unreleased: remove every `ConditionError` mention (~257) and add
809
+ a BREAKING entry "`where`/`guard` removed". Snippet: `where { |ctx| ctx.get_input(:enabled) }` →
810
+ `run { |args, ctx| next Skipped(nil) unless ctx.get_input(:enabled); … }`. Note that a
811
+ `background before:` hand-off at that step now always fires.
812
+ - [X] T102 [US6] Demo check (Constitution VI, FR-025): `demo_app/app/reactors/signal_demo_reactor.rb`,
813
+ its rake task and `demo_app/spec/reactors/signal_demo_reactor_spec.rb` already show a step
814
+ returning `Skipped`. Confirm with `grep -n "Skipped\|skip!"`. Confirm no demo file uses `where`/`guard`
815
+ (`grep -rnE "\b(where|guard)\s*(\{|do)" demo_app`).
816
+
817
+ **Checkpoint**: US6 works on its own.
818
+
819
+ ---
820
+
821
+ ## Phase 11: Revision — User Story 3: every exception rolls back except interruptions (Priority: P1)
822
+
823
+ **Goal**: every exception raised by reactor code, standard or not, fails the step and rolls back.
824
+ Only interruptions (`SignalException`, `SystemExit`, `NoMemoryError`, `Timeout::ExitException`)
825
+ skip rollback and mark an inline run `aborted`. An interruption mid-rollback leaves only the entries
826
+ not yet undone (R-16, FR-013, FR-016, FR-018, FR-028, DM §3/§10/§11).
827
+
828
+ **Independent Test**: `bundle exec rspec spec/ruby_reactor/rollback/failure_rollback_spec.rb spec/ruby_reactor/rollback/aborted_execution_spec.rb`,
829
+ matching RS §3 rows S-edge-03 and S-edge-03b.
830
+
831
+ ### Tests for User Story 3 (revision) ⚠️ write first, confirm they FAIL
832
+
833
+ - [X] T103 [P] [US3] Add to `spec/ruby_reactor/rollback/failure_rollback_spec.rb` (define
834
+ `class Crash < Exception; end` in the spec module; add `# rubocop:disable Lint/InheritException`
835
+ if needed). Each on `a → b`:
836
+ - (a) `b`'s body raises `Crash`: `run:a run:b compensate:b undo:a`, a `Failure` is returned (no
837
+ raise), `step_name == :b`, `exception_class == "…Crash"`.
838
+ - (b) `b` is a step class with no `run` (so `NotImplementedError`): `a` undone, failure names `b`,
839
+ `exception_class == "NotImplementedError"`.
840
+ - (c) `b`'s body raises `SystemStackError`: rolled back the same way.
841
+ - (d) `b`'s argument transform raises `Crash`: `run:a undo:a`, `b` not compensated,
842
+ `exception_class` names `Crash`.
843
+ - (e) FR-028: `a → b → c`, `c` fails, `b`'s undo raises `Crash`: `a` is still undone, and
844
+ `rollback_failures` has one entry for `b` with `kind: :undo`.
845
+ - (f) FR-028: `b` fails and its compensate raises `Crash`: `a` is still undone, and the failure
846
+ names `b`.
847
+ - [X] T104 [P] [US3] Rework `spec/ruby_reactor/rollback/aborted_execution_spec.rb`:
848
+ - Change the `Crashes` fixture to `raise Interrupt` (keep the exception-identity assertion) and
849
+ the describe text to "a run cut short by an interruption".
850
+ - Add: `SystemExit` raised by a body also aborts (re-raised, status `aborted`).
851
+ - Add: `Timeout.timeout(0.05) { reactor.run(...) }` around a body that sleeps 1s raises
852
+ `Timeout::Error` to the caller, and the stored run is `aborted`.
853
+ - Add: interruption during rollback. `a → b → c`, `c` fails, `b`'s undo raises `Interrupt`: the
854
+ `Interrupt` reaches the caller, and the stored undo stack holds `a` and `b` but not `c`'s
855
+ entry. A manual `Reactor#undo` then records `undo:b undo:a` and nothing else.
856
+
857
+ ### Implementation for User Story 3 (revision)
858
+
859
+ - [X] T105 [US3] Create `lib/ruby_reactor/error/rescuable.rb`: `module RubyReactor::Error::Rescuable`
860
+ with `def self.===(exception)`. It is true for any `Exception` that is not an interruption. The
861
+ interruptions are `SignalException`, `SystemExit`, `NoMemoryError`, and `::Timeout::ExitException`
862
+ when it is defined (check with `defined?` at call time, since `timeout` may load later). Add a
863
+ comment citing 008 R-16: why these four, and that the rest is reactor code's own failure.
864
+ - [X] T106 [US3] Replace `rescue StandardError` with `rescue Error::Rescuable` (qualified as each
865
+ file's namespace requires) at the user-code sites. Keep every more specific rescue before it,
866
+ unchanged:
867
+ - `lib/ruby_reactor/dsl/step_builder.rb` `resolve_arguments`
868
+ - `lib/ruby_reactor/executor/step_executor.rb` `safe_execute_step_sync`
869
+ - `lib/ruby_reactor/executor/compensation_manager.rb` `compensate_step` and `undo_step`
870
+ - `lib/ruby_reactor/executor.rb` `execute` and `resume_execution`
871
+ - `lib/ruby_reactor/step_worker.rb` `perform` (~97) and the body call (~373)
872
+ - `lib/ruby_reactor/executor/step_coordination.rb` `resolve_key` (~102)
873
+ - `lib/ruby_reactor/step/map_step.rb` `process_results` (~261)
874
+ - `lib/ruby_reactor/map/collector.rb` (~100, ~121)
875
+ - `lib/ruby_reactor/map/helpers.rb` `apply_collect_block` (~44)
876
+ - [X] T107 [US3] In `lib/ruby_reactor/executor.rb`, update the `mark_aborted` comment and the two
877
+ `rescue Exception` comments: only interruptions reach them now (R-16).
878
+ - [X] T108 [US3] In `lib/ruby_reactor/executor/compensation_manager.rb` `rollback_completed_steps`,
879
+ undo newest first and pop each entry only after its `undo_step` returns (`until undo_stack.empty?`
880
+ over `undo_stack.last`, then `undo_stack.pop`), instead of `reverse_each` plus `clear`. Comment:
881
+ an interruption leaves exactly the entries not yet undone for a manual undo (R-16).
882
+ - [X] T109 [US3] Run `failure_rollback_spec.rb`, `aborted_execution_spec.rb`, `spec/ruby_reactor/rollback/`,
883
+ `spec/ruby_reactor/executor/` and `spec/ruby_reactor/map/`. All must be green.
884
+
885
+ ### Documentation & demo for User Story 3 (revision)
886
+
887
+ - [X] T110 [P] [US3] Docs: `documentation/interrupts.md` (~165): `aborted` only for interruptions
888
+ (signal, exit, out of memory, an enclosing timeout); any other exception, standard or not, rolls
889
+ back. `documentation/core_concepts.md` Rollback Rule: add the exception rule (RS §1 rule 6).
890
+ `README.md` (~1432): "Every exception after a completed step rolls back, standard or not, except
891
+ interruptions", with `NotImplementedError` and a custom `Exception` as examples.
892
+ - [X] T111 [P] [US3] `CHANGELOG.md` Unreleased: rewrite the `aborted` entries (~173, ~181) to say
893
+ interruptions only, and add a BREAKING entry: exceptions that are not `StandardError` (except
894
+ interruptions) now fail the step and roll back instead of propagating out of `Reactor.run`. Note
895
+ that a test assertion error raised inside a step body now surfaces as the step's `Failure`.
896
+ - [X] T112 [US3] Demo (FR-025): in `demo_app/app/reactors/argument_failure_demo_reactor.rb`, give
897
+ `ArgumentFailureChargeStep` an `argument :sku` input and make its `run` raise
898
+ `NotImplementedError, "legacy gateway"` when `sku == "legacy"`. Update the header comment: the
899
+ transform case is not compensated, the `NotImplementedError` case is compensated, and both undo
900
+ `reserve`. In `demo:failure_rollback` (`demo_app/lib/tasks/demo_reactors.rake` ~265), run both
901
+ cases and print each log and failure (`step_name`, `exception_class`).
902
+ - [X] T113 [P] [US3] Extend `demo_app/spec/reactors/argument_failure_demo_reactor_spec.rb` (shipped
903
+ matchers only) with the `legacy` case: `be_failure`, `reserve` released, charge compensated,
904
+ `exception_class == "NotImplementedError"`.
905
+
906
+ **Checkpoint**: US3 revision works on its own.
907
+
908
+ ---
909
+
910
+ ## Phase 12: Revision — User Story 5: harness and 007 references
911
+
912
+ - [X] T114 [US5] In `specs/007-execution-flow-analysis/evidence/probes/02_compose.rb`:
913
+ - Move `Compose05` inside the S-compose-05 scenario block, and expect
914
+ `=> raised(RubyReactor::Error::DeprecatedDslError)` (rescue it and return that label, as S-edge-03
915
+ does).
916
+ - Change `Compose05b` to use a child whose `c2` declares `retries: { max_attempts: 2, base_delay: 0 }`
917
+ with `fail_times: 1`, and no compose-level retries. Expected (RS §3): `run:child.c1 run:child.c2
918
+ retry:c2#1 run:child.c2 run:b compensate:b undo:child.c2 undo:child.c1 => failure(b)`. Update
919
+ both scenario titles.
920
+ - [X] T115 [US5] In `specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb`:
921
+ - S-edge-03 (`Crash < Exception`) expects `run:a run:b compensate:b undo:a => failure(b)`.
922
+ - Add S-edge-03b: `b` raises `Interrupt`, expecting `run:a run:b => raised(Interrupt)`, with a note
923
+ of the stored status (`aborted`).
924
+ - Move `Edge04` inside S-edge-04's block and expect `=> raised(RubyReactor::Error::DeprecatedDslError)`.
925
+ Then run `bundle exec ruby specs/007-execution-flow-analysis/evidence/run.rb | tee specs/007-execution-flow-analysis/evidence/output.txt`:
926
+ `64 scenarios, 64 match, 0 mismatch`, and `git diff` on the probes touches only those scenarios.
927
+ - [X] T116 [P] [US5] Update `specs/007-execution-flow-analysis/analysis/invariants.md` rows INV-06, 07 and
928
+ 13 (RS §4), and the failure-kinds table in `execution-order.md` (non-`StandardError` rows, and the
929
+ compose-retry row of the R-05 audit table: it is now rejected at definition time).
930
+
931
+ ---
932
+
933
+ ## Phase 13: Revision — Polish & cross-cutting
934
+
935
+ - [X] T117 Documentation consistency pass (SC-007, SC-011): `grep -rnE "\bwhere\b|\bguard\b|ConditionError"
936
+ README.md documentation` and a search for `retries` inside a `compose`/`async_reactor` must hit
937
+ only migration notes or unrelated prose (the English word "where", the deadlock guard). Also check
938
+ that `aborted` is described as interruptions-only everywhere.
939
+ - [X] T118 Consolidate `CHANGELOG.md`: the Unreleased "Migration notes" block lists every R-12
940
+ breaking row, including the three revision rows, each with a before/after snippet. No entry
941
+ still describes compose retries re-running the child, `ConditionError`, or `aborted` for every
942
+ non-standard exception.
943
+ - [X] T119 Run `bundle exec rubocop` and `bundle exec rspec` (full suite, alone), then
944
+ `bundle exec rspec spec/ruby_reactor/rollback --tag slow`. Fix every spec that relied on a
945
+ non-`StandardError` propagating out of a step body (plan Risks).
946
+ - [X] T120 Demo acceptance per [quickstart.md](quickstart.md) §5 (isolated compose project): run
947
+ `bin/rails demo:rollback_reliability` and the four rollback demo specs, then the whole demo spec
948
+ suite.
949
+ - [X] T121 Run [quickstart.md](quickstart.md) §1–§6 end to end and record the results
950
+ (SC-001..SC-011) for the PR.
951
+
952
+ ---
953
+
954
+ ## Phase 14: Review follow-ups (2026-09-27, after `/speckit-review`)
955
+
956
+ Review answers: `Skipped` is only an instrumentation mark and has every effect of `Success`
957
+ (FR-029, FR-030, R-19); cap stored backtraces (FR-031, R-20); a resume is accepted only while
958
+ paused at an interrupt (FR-032).
959
+
960
+ - [X] T122 [US6] Specs first: `spec/ruby_reactor/dsl/reactor_background_spec.rb` (an `after:` step
961
+ returning `Skipped` hands off; one returning `Halt` does not), `spec/ruby_reactor/step_coordination/primitives_spec.rb`
962
+ (a body `Skipped` marks the period bucket), `spec/ruby_reactor/rollback/removed_dsl_spec.rb` and
963
+ `spec/ruby_reactor/skipped_rollback_spec.rb` (a `Skipped` step is undone like a `Success`).
964
+ - [X] T123 [US6] `lib/ruby_reactor/executor/result_handler.rb` `handle_skipped` goes through
965
+ `handle_success` (undo enrollment) and only adds the trace entry; `lib/ruby_reactor/executor/step_executor.rb`
966
+ `handoff_after?` excludes `Halt` instead of `Skipped`; `lib/ruby_reactor/executor/step_coordination.rb`
967
+ marks the period for a body `Skipped` and clears contention state on any `Error::Rescuable`.
968
+ - [X] T124 [P] [US6] Docs: README (signals list, "Skipping a single step"), `documentation/core_concepts.md`,
969
+ `documentation/background_and_async.md` (hand-off bullet), `documentation/locks_and_semaphores.md`
970
+ (acquisition order, period), `documentation/testing.md`; `lib/ruby_reactor.rb` `Skipped` comment.
971
+ - [X] T125 [P] [US3] `lib/ruby_reactor.rb` `Failure::MAX_BACKTRACE_FRAMES = 100`, with a spec in
972
+ `spec/ruby_reactor/failure_reporting_spec.rb`.
973
+ - [X] T126 [US3] Spec first, `spec/ruby_reactor/rollback/resume_guard_spec.rb` (resume during
974
+ compensation fails; resume of an aborted run fails); then `lib/ruby_reactor/reactor.rb` `continue`
975
+ rejects a non-`paused` run and persists `running` before executing.
976
+ - [X] T127 [P] Docs: `documentation/interrupts.md` (paused-only resume); `CHANGELOG.md` (breaking
977
+ `Skipped` undo with migration note, `after:` hand-off and `Halt`, period mark, backtrace cap,
978
+ paused-only resume, migration note 5 on validation/coordination before the body skips).
979
+ - [X] T128 Re-run the full suite, rubocop, the 007 harness and the Docker demo suite.
980
+ - [X] T129 Sync spec.md (FR-029 revised, FR-030–FR-032), research R-19/R-20, contracts.
981
+ - [X] T130 [US6] Revert undo enrollment of `Skipped` (user direction: skipped steps do not undo):
982
+ `lib/ruby_reactor/executor/result_handler.rb` `handle_skipped`, the `Skipped` comment in
983
+ `lib/ruby_reactor.rb`, `spec/ruby_reactor/rollback/removed_dsl_spec.rb`,
984
+ `spec/ruby_reactor/skipped_rollback_spec.rb`, probe S-plain-06, README, `documentation/`,
985
+ CHANGELOG (drop the "`Skipped` steps are undone" migration note), 007 INV-05/R5, 008 artifacts.
986
+ - [X] T131 Record the review's deferred items in `specs/future_improvements.md` (concurrent resume of
987
+ a second pending interrupt, interrupted failing-step `compensate`, same-instant resumes) and in
988
+ spec.md (FR-032, Assumptions).
989
+
990
+ ---
991
+
992
+ ## Dependencies & Execution Order
993
+
994
+ ### Phase Dependencies
995
+
996
+ - **Setup (T001–T003)**: none.
997
+ - **Foundational (T004–T009)**: after Setup. **Blocks every story.**
998
+ - **US1, US2, US3, US4**: each depends only on Foundational, so they can run in parallel.
999
+ - **US5**: T067/T068 depend only on Foundational. T069–T072 depend on US1–US4 (the harness and
1000
+ docs describe their final behavior).
1001
+ - **Polish (T073–T079)**: after all stories.
1002
+ - **Revision (T080–T121)**: after T079. Phases 9 (US2), 10 (US6) and 11 (US3) are independent of each
1003
+ other, except for the shared files listed below. Phase 12 needs 9–11. Phase 13 comes last.
1004
+
1005
+ ### Shared-file ordering (tasks without [P] across stories)
1006
+
1007
+ These files are edited by more than one story. Serialize the edits in story order, or rebase before
1008
+ editing:
1009
+
1010
+ - `README.md`: T022, T050, T062, T072, T075
1011
+ - `CHANGELOG.md`: T023, T031, T051, T063, T076
1012
+ - `demo_app/lib/tasks/demo_reactors.rake`: T025, T033, T053, T065, T073
1013
+ - `lib/ruby_reactor/dsl/step_builder.rb`: T004, T007, T039, T058, T068
1014
+ - `lib/ruby_reactor/step_worker.rb`: T006, T043, T057
1015
+ - `lib/ruby_reactor/executor/compensation_manager.rb`: T008, T040
1016
+ - `lib/ruby_reactor/executor/result_handler.rb`: T042, T068, T097
1017
+ - Revision: `CHANGELOG.md` T090, T101, T111, T118; `README.md` T099, T100, T110;
1018
+ `documentation/core_concepts.md` T099, T100, T110; `step_builder.rb` T093, T106;
1019
+ `step_executor.rb` T095, T106; `step_worker.rb` T096, T106; `compensation_manager.rb` T097, T106,
1020
+ T108; `compose_builder.rb` / `async_reactor_builder.rb` T082/T083, T094;
1021
+ `failure_rollback_spec.rb` T092, T103
1022
+
1023
+ ### Within each story
1024
+
1025
+ The spec tasks (FAIL first) come before the implementation, then a green run of the story's specs,
1026
+ then docs, demo and CHANGELOG. The story is not done until its docs and demo land (Constitution
1027
+ Development Workflow and VI).
1028
+
1029
+ ### Story independence
1030
+
1031
+ - **US1**: needs only T008 (compensate runs under `with_step`).
1032
+ - **US2**: needs no other story.
1033
+ - **US3**: US4's T057 treats a never-started error as not compensated. Without US3's classes, a
1034
+ worker-side resolution error still never reaches the compensate call, because it raises before
1035
+ the body.
1036
+ - **US4**: independent of US3.
1037
+ - **US5**: T068 is independent. Its doc and harness tasks come last.
1038
+
1039
+ ---
1040
+
1041
+ ## Parallel Examples
1042
+
1043
+ ```text
1044
+ # After Phase 2, write the failing specs of all P1 stories together:
1045
+ T010 map_rollback_spec.rb T011 map_fan_out_settle_spec.rb T012 map_scale_spec.rb
1046
+ T027 compose_retry_spec.rb T035 failure_rollback_spec.rb T036 aborted_execution_spec.rb
1047
+
1048
+ # US1 implementation: independent files in parallel
1049
+ T015 redis_adapter.rb T019 result_enumerator.rb (then T013/T014/T016/T017/T018)
1050
+
1051
+ # US3 implementation: new files and status lists in parallel
1052
+ T037 argument_resolution_error.rb T038 condition_error.rb T045 worker.rb T046 scan/api T047 gui
1053
+
1054
+ # Demo artifacts per story ([P]): reactor + spec in parallel, rake task after
1055
+ T024 + T026 → T025
1056
+ ```
1057
+
1058
+ ---
1059
+
1060
+ ## Implementation Strategy
1061
+
1062
+ ### MVP (US1 only)
1063
+
1064
+ 1. Phase 1 → Phase 2 (T009 green, no behavior change).
1065
+ 2. Phase 3 (US1): map rollback in both modes, plus its docs and demo.
1066
+ 3. **Stop and validate**: quickstart §1 for the map specs, plus demo `demo:map_rollback`. This
1067
+ closes the widest gap (F-01, F-05).
1068
+
1069
+ ### Incremental delivery
1070
+
1071
+ After the MVP, in order. Each step is one PR-sized increment with its own docs, demo and CHANGELOG:
1072
+
1073
+ 1. US3 (failures): the smallest change. It gives every later failure its attribution.
1074
+ 2. US2 (compose retry).
1075
+ 3. US4 (async_step).
1076
+ 4. US5 (one rule, harness and 007 docs refresh).
1077
+ 5. Polish.
1078
+
1079
+ ### Revision order (T080–T121)
1080
+
1081
+ 1. US6 first (T091–T102). It deletes code and specs that US3's rescue swap would otherwise have to
1082
+ touch.
1083
+ 2. US2 (T080–T090).
1084
+ 3. US3 (T103–T113).
1085
+ 4. Phase 12, then Phase 13.
1086
+
1087
+ ### Commits
1088
+
1089
+ - Breaking items use `feat!`/`fix!` with a `BREAKING CHANGE:` footer (R-12): T013/T014 (map), T057
1090
+ (async compensate) and T059 (inline `undo` rejected).
1091
+ - Everything else uses `feat:`/`fix:`/`docs:`/`test:`/`refactor:` (Phase 2 is `refactor:`).
1092
+ - Revision breaking commits (`feat!:` + `BREAKING CHANGE:` footer): T082/T083 (`retries` on nested
1093
+ reactors removed), T093 (`where`/`guard` removed), T106 (non-`StandardError` exceptions roll back).
1094
+
1095
+ ---
1096
+
1097
+ ## Phase 15: Convergence
1098
+
1099
+ From `/speckit-review` (2026-09-30), findings F1–F7. Each fix is spec-first against real Redis;
1100
+ the review probes (P1, P1b, P2, P3, P5, P6) are the failing cases.
1101
+
1102
+ - [X] T132 CRITICAL Spec first in `spec/ruby_reactor/rollback/aborted_execution_spec.rb`: an interruption raised during a rollback that runs from the executor's `rescue Error::Rescuable` body (a step-argument validation failure after `a` completed, `a`'s undo interrupted; and the same on `resume_execution`) stores the run `aborted`, keeps the not-yet-undone entries, and `RubyReactor::Sweeper.run_once` leaves it alone; then make `lib/ruby_reactor/executor.rb` `execute`/`resume_execution` mark aborted for interruptions raised inside the rescue body per Constitution II, FR-018, Edge Cases (interruption during a running rollback) (contradicts)
1103
+ - [X] T133 CRITICAL Spec first in `spec/ruby_reactor/rollback/aborted_execution_spec.rb`: after an interruption inside a composed child (`a → compose(c1 → c2 raises Interrupt)`) and inside an inline map element (`a → map[0,1,2]`, element 1 interrupted), `Reactor.undo(id)` undoes `child.c1` / `e.e1[0]` before `a`; then, when a run is aborted while a `compose` or inline `map` step is in flight, track that step for undo (`ComposeStep#undo` / `MapStep#undo` replay from stored state) per Constitution II, FR-018, US3/AC5, SC-004 (partial)
1104
+ - [X] T134 Spec first in `spec/ruby_reactor/rollback/resume_guard_spec.rb`: a `continue` that hits reactor-level lock or semaphore contention raises the contention error and leaves the run `paused`, and a `continue` after the holder releases resumes it; then fix `lib/ruby_reactor/reactor.rb` `continue` (restore `paused` and save on contention before re-raising) per FR-032 (partial)
1105
+ - [X] T135 Spec first in `spec/ruby_reactor/rollback/map_rollback_spec.rb` (inline) and `spec/ruby_reactor/rollback/map_fan_out_settle_spec.rb` (fan-out): with the map's element index expired while the parent is alive, every completed element is reported `reason: :context_unavailable`, never skipped silently; then store the element count on the map's undo record (one Integer: `lib/ruby_reactor/map/helpers.rb` fan-out entry, derived from the source for inline) and have `lib/ruby_reactor/step/map_step.rb` `completed_elements` report each index the stored ids do not cover per FR-005, FR-008, plan Risks (element contexts that expire) (partial)
1106
+ - [X] T136 Spec first in `spec/ruby_reactor/rollback/map_fan_out_settle_spec.rb`: a collector that finds `map_collect:<map_id>` held re-enqueues itself with a short delay instead of dropping, so a fail-fast failure is applied without `Map::Sweeper`; then fix `lib/ruby_reactor/map/collector.rb` `perform` (and the router call it needs) per FR-003, US1/AC3, research R-04 (partial)
1107
+ - [X] T137 Spec first: the same element-undo failure gives the same top-level `Failure` shape (`exception_class`, `step_name`, message) in inline and fan-out mode, with identical `rollback_failures`; then align `lib/ruby_reactor/map/helpers.rb` `resume_parent_execution` with the inline `CompensationError` path per FR-005, US1/AC5, FR-017 (partial)
1108
+ - [X] T138 [P] Delete the stale "`compose` with `retries`" row from the Rollback Rule table in `documentation/core_concepts.md` per FR-024, SC-011 (contradicts)
1109
+ - [X] T139 [P] Docs and CHANGELOG for T132–T137: `documentation/interrupts.md` (aborted undo covers an in-flight compose child / inline map element; a contended `continue` stays `paused` and can be retried), `documentation/locks_and_semaphores.md` (contention on `continue`), `documentation/data_pipelines.md` (`context_ttl` horizon counted from the map's run; missing elements reported), `CHANGELOG.md` `### Fixed` entries per Constitution Development Workflow, FR-024, FR-026 (missing)
1110
+ - [X] T140 Re-run the full gem suite, `bundle exec rubocop`, the four rollback demo specs (`demo_app`, Redis DB 5) and the 007 harness; confirm probes P1–P6 now pass as regression specs per SC-009 (missing)