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,561 @@
1
+ # Feature Specification: Reliable Rollback Across Constructs
2
+
3
+ **Feature Branch**: `execution_flow_analysis`
4
+
5
+ **Created**: 2026-09-26
6
+
7
+ **Status**: Draft, revised 2026-09-27 after the PR #65 review
8
+
9
+ **Input**: User description: "In this investigation specs/007-execution-flow-analysis/analysis/findings-and-options.md
10
+ we found issues that we should fix and make the flows predictable and reliable. This round is to at
11
+ least fix the High issues. We should consider if some refactor is needed. For example: Now that we
12
+ moved more features into the Step we could consider the step holding more logic and functionality
13
+ for its own processing and execution and lean the reactor coordination simpler delegating more
14
+ logic to the step. This is only a suggestion and maybe that not the solution, but it's worth
15
+ exploring."
16
+
17
+ ## Context
18
+
19
+ The 007 analysis ([findings-and-options.md](../007-execution-flow-analysis/analysis/findings-and-options.md))
20
+ found four **High** findings. Each one leaves completed side effects in place without saying so, or
21
+ treats rolled-back work as done:
22
+
23
+ | Finding | Gap | Invariants |
24
+ | --- | --- | --- |
25
+ | F-01 | Map elements that succeeded are never rolled back, whether the map fails or a later step fails | INV-19, INV-20 |
26
+ | F-02 | A retried composed reactor resumes a child whose earlier steps were already undone | INV-13 |
27
+ | F-03 | Some failures (argument preparation errors, non-standard exceptions) skip rollback entirely | INV-06 |
28
+ | F-04 | An async step's own `compensate`/`undo` are accepted by the DSL but never run | INV-24 |
29
+
30
+ This feature closes those four. It also closes the Medium/Low findings that the same rules fix:
31
+ F-05 (fan-out leftovers depend on scheduling), which has to be solved for F-01 to hold in fan-out
32
+ mode; F-06 (a raising condition compensates a step that never started), closed by removing
33
+ `where`/`guard`; and F-13 (failures without step attribution), which follows the same attribution
34
+ rule as F-03.
35
+
36
+ **Revision after the PR #65 review (2026-09-27)**. Four points in the first implementation were
37
+ wrong and are corrected here:
38
+
39
+ | Review point | Before | Now |
40
+ | --- | --- | --- |
41
+ | A nested reactor must never be retried as a whole by its parent | `retries` on a `compose` started a fresh child per attempt | `retries` on a `compose` or an `async_reactor` is rejected. The child's own steps declare their retries. This closes F-02 by removing the path (007 option O-02-c) |
42
+ | `where`/`guard` are an old implementation that may be stale | kept, with raising conditions made "never started" | removed from the DSL. A step that should not run returns `Skipped` from its body. This closes F-06 by removing the path |
43
+ | All errors raised by reactor code must roll back | only standard errors rolled back. Every other exception marked the run aborted | every exception raised by reactor code rolls back. Only process-termination exceptions skip rollback |
44
+ | `Skipped` can mean "not required" or "already done" | a `Skipped` step was never undone, and it also suppressed an `after:` hand-off and a period mark | `Skipped` is an instrumentation mark that never changes execution; a skipped step is still never undone (FR-029, FR-030) |
45
+
46
+ **Readers**: reactor authors, who need to predict what gets rolled back, and RubyReactor
47
+ maintainers, who need to change rollback behavior safely.
48
+
49
+ ## Clarifications
50
+
51
+ ### Session 2026-09-26
52
+
53
+ - Q: How should a map roll back the elements that succeeded? → A: Automatic replay of each
54
+ succeeded element's own step `undo`s, the same as compose. No new map DSL (FR-006).
55
+ - Q: When should an `async_step`'s own `compensate` run? → A: Unit-local: once, in the unit's own
56
+ job, when its body finally fails. `undo` on an `async_step` is rejected at definition time
57
+ (FR-019, FR-020). Refined in planning: an `undo` inherited from a step class is warned, not
58
+ rejected (research R-09).
59
+
60
+ ### Session 2026-09-27 (PR #65 review)
61
+
62
+ - Q: Should a `compose` keep `retries`, with a fresh child per attempt? → A: No. A composed reactor
63
+ is never retried as a whole by its parent. The child knows how to retry its own steps. `retries`
64
+ on a `compose` is rejected at definition time (FR-009, FR-010). This replaces the fresh-child
65
+ decision (research R-05). The same rule applies to `async_reactor`, the other construct that runs
66
+ a nested reactor.
67
+ - Q: Should `where`/`guard` stay? → A: No. Remove both completely. A step that decides it should
68
+ not run returns `Skipped` from its body (FR-014).
69
+ - Q: Which exceptions skip rollback? → A: Only the ones that mean the process is ending: a signal
70
+ (including an interrupt), a request to exit, and out of memory. Any other exception raised by
71
+ reactor code (a `run`, `compensate` or `undo` body, an argument source or transform, a step
72
+ definition) is a failure and rolls back, whether or not it is a standard error (FR-016, FR-018,
73
+ FR-028).
74
+ - Q: Is a `Skipped` step undone? → A: *(first defaulted to "no"; superseded by the review answer
75
+ below)*.
76
+ - Q (review 2026-09-27): What does `Skipped` change? → A: Nothing in execution. `Skipped` is an
77
+ instrumentation mark, so an engineer reviewing the execution can see the step did not need to
78
+ run: the run continues, a `background` hand-off happens as for a completed step, and a period
79
+ bucket is marked (FR-030). Rollback is the exception: a skipped step is never undone (FR-029;
80
+ briefly changed to "undone like Success", then reverted the same day).
81
+ - Q (review 2026-09-27): Cap a stored failure's backtrace? → A: Yes (FR-031).
82
+ - Q (review 2026-09-27): What must happen when a resume arrives while the reactor is compensating?
83
+ → A: The resume fails; a resume is accepted only by a reactor paused at an interrupt step
84
+ (FR-032).
85
+
86
+ ## User Scenarios & Testing *(mandatory)*
87
+
88
+ ### User Story 1 - Succeeded map elements are rolled back (Priority: P1)
89
+
90
+ A reactor author uses a map to charge a list of orders. If the map fails partway through, or a later
91
+ step fails after the map completed, every order that was charged is refunded. This is the same
92
+ behavior a composed reactor's completed steps already get.
93
+
94
+ **Why this priority**: F-01 is the widest gap. Map is the main batch construct, the README promises
95
+ automatic rollback for it, and no test covers the case today.
96
+
97
+ **Independent Test**: Run a reactor `a → map(4 elements) → b` whose element steps record `run` and
98
+ `undo`. Check the recorded sequence (a) when element 2 fails and (b) when `b` fails. Do this in
99
+ inline mode and in fan-out mode.
100
+
101
+ **Acceptance Scenarios**:
102
+
103
+ 1. **Given** an inline fail-fast map where elements 0 and 1 succeed and element 2 fails, **When**
104
+ the map fails, **Then** element 2 rolls itself back, then elements 1 and 0 are rolled back, then
105
+ the steps before the map are undone. The failure names the map step and the failing element.
106
+ 2. **Given** a map that completed, **When** a later step fails, **Then** every succeeded element is
107
+ rolled back at the map's position in the parent's reverse-completion order, before earlier steps
108
+ are undone.
109
+ 3. **Given** a fan-out fail-fast map where other elements are still running or already finished
110
+ when element 2 fails, **When** the map fails, **Then** every element that succeeded is rolled back,
111
+ including elements that finish after the failure was detected. The set of rolled-back elements
112
+ is the set of succeeded elements, whatever order the jobs ran in.
113
+ 4. **Given** a map with `fail_fast false` that has both successes and failures, **When** a later step
114
+ fails, **Then** the successful elements are rolled back and the failed elements (already
115
+ self-rolled-back) are not rolled back again.
116
+ 5. **Given** one element whose rollback fails, **When** the map rolls back, **Then** the other
117
+ elements still roll back, and the final failure's rollback failures list that element's failure
118
+ with the map step and element position.
119
+ 6. **Given** nesting (a map inside a composed reactor, or a composed reactor inside a map element),
120
+ **When** rollback runs, **Then** it unwinds innermost-first, as composed reactors already do.
121
+ 7. **Given** an execution containing a completed map, **When** it is undone manually, **Then** the
122
+ map's succeeded elements are rolled back too.
123
+ 8. **Given** an existing map whose element steps already declare `undo`, **When** a later step
124
+ fails, **Then** those `undo` blocks now run for every succeeded element. No map-level declaration
125
+ is needed.
126
+
127
+ ---
128
+
129
+ ### User Story 2 - A nested reactor is never retried as a whole (Priority: P1)
130
+
131
+ A reactor author wants a sub-workflow to survive a transient failure. They declare `retries` on the
132
+ child's steps that can fail transiently. The child retries those steps itself. The parent never
133
+ re-runs the child as a whole: a child whose steps ran out of retries has failed, has rolled itself
134
+ back, and fails the parent. Declaring `retries` on the `compose` (or `async_reactor`) itself is
135
+ rejected, with a message that says where the retries belong.
136
+
137
+ **Why this priority**: F-02 today returns a success built on a result that was already undone. A
138
+ later failure then undoes only part of the child. It is silent data corruption. Retrying the whole
139
+ child from the parent is the path that causes it. The child already owns its steps' retry policies,
140
+ so the parent-level retry adds nothing but a second, conflicting retry layer.
141
+
142
+ **Independent Test**: Define a reactor that declares `retries` on a `compose` and check that the
143
+ class definition is rejected. Separately, run a composed child `c1 → c2` where `c2` declares
144
+ `retries` and fails on its first attempt only. Check that `c1` runs once, `c2` runs twice, the
145
+ compose succeeds, and a later parent failure undoes `c2` then `c1` once each.
146
+
147
+ **Acceptance Scenarios**:
148
+
149
+ 1. **Given** a reactor that declares `retries` on a `compose`, in either the class form or the
150
+ inline block form, **When** the reactor class is defined, **Then** the definition is rejected
151
+ with a message naming the compose step and saying that retries belong on the child's own steps.
152
+ 2. **Given** a reactor that declares `retries` on an `async_reactor`, **When** the reactor class is
153
+ defined, **Then** it is rejected the same way.
154
+ 3. **Given** a composed child `c1 → c2` where `c2` declares `retries` and fails on its first attempt
155
+ only, **When** the parent runs, **Then** `c1` runs once, `c2` is retried inside the child, and the
156
+ compose result is the child's result.
157
+ 4. **Given** the same reactor, where a later parent step then fails, **When** rollback runs,
158
+ **Then** `c2` and then `c1` are each undone exactly once.
159
+ 5. **Given** a composed child whose step fails after its own retries are exhausted, **When** the
160
+ child fails, **Then** the child rolls back its completed steps, the compose fails, the parent's
161
+ completed steps are undone, and the child is not run again.
162
+ 6. **Given** a child that is parked mid-run (contention wait or background hand-off) and later
163
+ resumed, **When** it resumes, **Then** the steps it completed before the park are **not** run
164
+ again. A resume is not a retry.
165
+ 7. **Given** any execution that resumes after a rollback, **When** it continues, **Then** no
166
+ rolled-back step is treated as completed.
167
+
168
+ ---
169
+
170
+ ### User Story 3 - Every failure after completed work rolls back (Priority: P1)
171
+
172
+ A reactor author writes an argument transform, a dynamic argument source or a result path that
173
+ raises. Or a step body raises an exception that is not a standard error: a step class that does not
174
+ implement `run`, a runaway recursion, a custom exception class. Step `a` has already completed. The
175
+ author expects `a` to be undone and the failure to name the step that failed. Today nothing is
176
+ rolled back, and the failure has no step name or is not returned at all.
177
+
178
+ **Why this priority**: F-03 breaks the README's core promise ("automatically triggers compensation")
179
+ for common coding mistakes, and gives the reader nothing to trace.
180
+
181
+ **Independent Test**: Run `a → b`, where `b`'s argument transform raises. Check that `a` is undone,
182
+ `b` is not compensated, and the failure carries the step name `b` and the reason. Repeat with `b`'s
183
+ body raising a not-implemented error and a custom exception that is not a standard error: `a` is
184
+ undone, `b` is compensated, and the failure names `b`.
185
+
186
+ **Acceptance Scenarios**:
187
+
188
+ 1. **Given** `a` completed and `b`'s argument transform, dynamic source or result path raises,
189
+ **When** the execution fails, **Then** `a` is undone, `b` is not compensated (its body never
190
+ started), and the failure names reactor, step `b` and the reason.
191
+ 2. **Given** `a` completed and `b`'s body raises an exception that is not a standard error and is
192
+ not a process-termination exception (for example a not-implemented error, a stack overflow, or a
193
+ custom exception class), **When** the execution fails, **Then** `b` is compensated, `a` is
194
+ undone, and the failure is returned to the caller naming reactor, step `b` and the original
195
+ exception class.
196
+ 3. **Given** argument preparation that happens in a worker (an async step unit, or the first step
197
+ after a `background before:` hand-off), **When** it raises, **Then** the same rule applies in
198
+ that process.
199
+ 4. **Given** any other unexpected exception during an execution after at least one step completed,
200
+ **When** it happens, **Then** the completed steps are rolled back and the failure reports any
201
+ rollback that did not complete.
202
+ 5. **Given** an inline execution interrupted by a process-termination exception (a signal including
203
+ an interrupt, a request to exit, or out of memory), **When** it propagates, **Then** no rollback
204
+ code runs in that process and the exception reaches the caller unchanged. The execution is
205
+ recorded as **aborted** with completed work outstanding, and the existing manual undo rolls it
206
+ back. Worker behavior (redelivery) is unchanged.
207
+ 6. **Given** a rollback in progress, **When** a `compensate` or `undo` raises an exception that is
208
+ not a process-termination exception, **Then** it is recorded as a rollback failure for that
209
+ step and the remaining rollback continues.
210
+ 7. **Given** a failing step whose own compensation also fails, **When** the failure is returned,
211
+ **Then** it still carries the reactor and step name.
212
+
213
+ ---
214
+
215
+ ### User Story 4 - An async step's rollback hooks run as declared (Priority: P2)
216
+
217
+ A reactor author declares `compensate`/`undo` on an `async_step`, as the documentation says works.
218
+ Today those blocks never run. They are accepted and silently ignored.
219
+
220
+ **Why this priority**: fewer reactors use `async_step` rollback hooks than maps or composes, but a
221
+ DSL that accepts dead code and documentation that promises it runs are both traps.
222
+
223
+ **Independent Test**: Run an `async_step` unit whose body always fails and that declares
224
+ `compensate`. Drain its job and check that `compensate` ran once, after the last attempt.
225
+ Separately, declare `undo` on an `async_step` and check that the class definition is rejected.
226
+
227
+ **Acceptance Scenarios**:
228
+
229
+ 1. **Given** an `async_step` whose body finally fails (after its retries) and which declares
230
+ `compensate`, **When** the unit fails, **Then** its `compensate` runs once, in the unit's own
231
+ job, after the last attempt. It runs whether or not any step reads the result.
232
+ 2. **Given** an `async_step` unit whose first attempt fails and whose retry succeeds, **When** it
233
+ completes, **Then** `compensate` never runs.
234
+ 3. **Given** a unit that compensated itself and a reader that then surfaces the unit's failure,
235
+ **When** the parent rolls back, **Then** the reader is compensated and the parent's completed
236
+ steps are undone, and the unit is **not** compensated a second time.
237
+ 4. **Given** an `async_step` that declares an inline `undo`, **When** the reactor class is defined,
238
+ **Then** the declaration is rejected with a message explaining why and where to put the cleanup
239
+ instead. If the `undo` comes from the step class, a warning is emitted instead.
240
+ 5. **Given** the documentation for async steps, **When** a reader follows it, **Then** it matches
241
+ the behavior.
242
+
243
+ ---
244
+
245
+ ### User Story 5 - One rollback rule for every construct (Priority: P3)
246
+
247
+ A reactor author reads a reactor class and predicts what a failure will roll back from one rule:
248
+ *completed work is undone, work that started and failed is compensated, work that never started is
249
+ left alone*. Each construct (step, composed reactor, map, async step, async reactor) says what
250
+ "undo" and "compensate" mean for itself. A maintainer who changes one construct's rollback changes
251
+ it in that construct.
252
+
253
+ **Why this priority**: this is the structural goal behind the first four stories (the user's "step
254
+ owns its own lifecycle" direction). It pays off only once they land, and it is judged by review
255
+ more than by any single test.
256
+
257
+ **Independent Test**: Rebuild the F-10 rollback coverage table from the new behavior. Compose and
258
+ map rows read the same. Async rows differ only by the documented independence of async units.
259
+
260
+ **Acceptance Scenarios**:
261
+
262
+ 1. **Given** the F-10 coverage table, **When** it is rebuilt after this feature, **Then** no cell
263
+ reads "left in place" for compose or map, and the async rows match the documentation.
264
+ 2. **Given** the coordinator that runs a reactor, **When** a maintainer reviews it, **Then** it
265
+ applies the one rule above to every construct. No construct kind is excluded from rollback by a
266
+ special case in the coordinator. The construct's own definition decides what its rollback does.
267
+
268
+ ---
269
+
270
+ ### User Story 6 - One way to skip a step (Priority: P2)
271
+
272
+ A reactor author wants a step to do nothing under some condition. There is one way to say it: the
273
+ step's body returns `Skipped`. The older `where`/`guard` declarations, which decided before the
274
+ step started and followed their own failure rules, no longer exist. A reactor that still declares
275
+ them is rejected when it is defined, with a message that shows the replacement.
276
+
277
+ **Why this priority**: `where`/`guard` is an old, barely documented path with its own failure
278
+ behavior (F-06). It duplicates `Skipped`, and every rollback rule has to account for it. Removing it
279
+ removes a whole failure category instead of classifying it.
280
+
281
+ **Independent Test**: Define a step that declares `where`, and another that declares `guard`. Check
282
+ that each class definition is rejected with a message pointing to `Skipped`. Rewrite the same step
283
+ to return `Skipped` from its body and check that the reactor continues past it.
284
+
285
+ **Acceptance Scenarios**:
286
+
287
+ 1. **Given** a step, async step or interrupt that declares `where` or `guard`, **When** the reactor
288
+ class is defined, **Then** the definition is rejected with a message naming the step and saying
289
+ to return `Skipped` from the step body instead.
290
+ 2. **Given** a step whose body returns `Skipped`, **When** the reactor runs, **Then** everything
291
+ happens exactly as for `Success` (value, hand-off, period mark), the execution trace records
292
+ the skip, and a later failure does not undo it.
293
+ 3. **Given** the README and `./documentation`, **When** a reader looks for `where`, `guard` or the
294
+ condition error, **Then** the only mentions are in the migration note.
295
+
296
+ ---
297
+
298
+ ### Edge Cases
299
+
300
+ - **Map with zero elements**: nothing to roll back. The map rollback is a no-op, not an error.
301
+ - **Every element fails (fail-fast)**: only the first failing element rolled back its own steps.
302
+ There are no succeeded elements to roll back.
303
+ - **Collect step raises after all elements succeeded**: the map fails, and every succeeded element is
304
+ rolled back.
305
+ - **Large map (10,000 elements)**: rollback must stay possible without the parent's stored state
306
+ growing past existing storage limits.
307
+ - **Element rollback under step locks**: element steps re-take their own locks for undo, the same as
308
+ any step undo today (including the `rollback_wait` skip-and-report behavior).
309
+ - **Element that returned `Halt`**: `Halt` semantics are unchanged. No rollback (F-07 is out of
310
+ scope).
311
+ - **Fan-out failure latency**: a fail-fast fan-out map reports failure only after the elements in
312
+ flight have finished and been rolled back. The latency grows to the slowest element in flight.
313
+ This is documented.
314
+ - **Existing reactor that declares `retries` on a `compose` or `async_reactor`**: it now fails when
315
+ its class is loaded, with the migration message. It does not silently run with one attempt.
316
+ - **Compose inline block that declares `retries` meaning "for the steps inside"**: rejected like any
317
+ compose-level `retries`. The message says to declare `retries` inside each child step.
318
+ - **Existing reactor that declares `where`/`guard`**: fails when its class is loaded, with the
319
+ migration message.
320
+ - **A `background before:` hand-off at a step that used a `where` condition**: the condition kept
321
+ the hand-off from happening. After migration the step's body returns `Skipped`, so the hand-off
322
+ happens at that step. This is documented in the migration note.
323
+ - **A step class that does not implement `run`**: its not-implemented error is a failure of that
324
+ step. Completed steps are undone.
325
+ - **A process-termination exception during a rollback that is already running**: the rollback
326
+ stops. The execution is recorded as aborted with the steps not yet undone still outstanding, and
327
+ manual undo finishes the rollback.
328
+ - **Awaited async result times out during argument preparation**: this already rolls back. It stays
329
+ unchanged.
330
+ - **Process-termination exception in a worker**: the job is redelivered and resumes from its last
331
+ checkpoint. Unchanged.
332
+ - **A step that returns `Skipped`, then a later step fails**: it is not undone (FR-029). The same
333
+ holds for the library's own skips (`with_period`, `with_ordered_lock`).
334
+ - **`background after: :x` where `:x` returns `Halt`**: the run halts; nothing is handed off.
335
+
336
+ ## Requirements *(mandatory)*
337
+
338
+ ### Functional Requirements
339
+
340
+ #### Map rollback (F-01, F-05)
341
+
342
+ - **FR-001**: When a map step fails, every element of that map that had succeeded MUST be rolled
343
+ back before the parent continues its own rollback.
344
+ - **FR-002**: When a step after a completed map fails, or the execution is undone manually, every
345
+ succeeded element of that map MUST be rolled back at the map step's position in reverse-completion
346
+ order.
347
+ - **FR-003**: Map rollback MUST produce the same set of rolled-back elements in inline and fan-out
348
+ modes. In fan-out mode it MUST include elements that were in flight when the failure was detected,
349
+ so that no succeeded element escapes rollback because of job scheduling.
350
+ - **FR-004**: An element that failed and already rolled itself back MUST NOT be rolled back again.
351
+ - **FR-005**: A rollback failure for one element MUST NOT stop rollback of the others. It MUST appear
352
+ in the final failure's rollback failures, attributed to the map step and the element position.
353
+ - **FR-006**: A succeeded element MUST be rolled back by replaying that element's own completed
354
+ steps' `undo`, in reverse completion order, exactly as a completed composed reactor is rolled back
355
+ today. The map adds no new rollback DSL; the `undo` blocks authors already write on element steps
356
+ become the element's rollback.
357
+ - **FR-007**: A map whose collection step fails after its elements ran MUST be treated as a failed
358
+ map. FR-001 applies to its succeeded elements.
359
+ - **FR-008**: Making maps rollback-capable MUST NOT make the parent execution's stored state grow per
360
+ element in a way that breaks maps that run today within storage limits.
361
+
362
+ #### Nested reactors are never retried as a whole (F-02)
363
+
364
+ - **FR-009** *(revised 2026-09-27)*: Declaring `retries` on a `compose` or an `async_reactor` MUST be
365
+ rejected when the reactor class is defined, in both the class form and the inline block form. The
366
+ message MUST name the step and say that retries belong on the child reactor's own steps.
367
+ - **FR-010** *(revised 2026-09-27)*: A parent MUST NOT run a failed nested child again. The only
368
+ retries inside a child are its own steps' retries. The compose result, and any later undo of the
369
+ compose, reflect the child's single run.
370
+ - **FR-011**: A resume that is not a retry (redelivery after a park, a contention wait, a background
371
+ hand-off) MUST keep resuming without re-running completed, not-rolled-back steps.
372
+ - **FR-012**: No execution MUST ever treat a rolled-back step as completed when it resumes.
373
+
374
+ #### Failures that skip rollback (F-03, F-06, F-13)
375
+
376
+ - **FR-013**: An exception raised while preparing a step's arguments (argument sources, transforms,
377
+ result paths) MUST fail that step, MUST NOT compensate it, and MUST undo all completed steps.
378
+ This covers every exception except the process-termination ones (FR-018).
379
+ - **FR-014** *(revised 2026-09-27)*: The `where` and `guard` step declarations MUST be removed.
380
+ Declaring either on a step, async step or interrupt MUST be rejected when the reactor class is
381
+ defined, with a message naming the step and saying to return `Skipped` from the step body instead.
382
+ With them goes the failure category they created (F-06): a condition that raises.
383
+ - **FR-015**: FR-013 MUST also hold when argument preparation happens in a worker process (an async
384
+ step unit, or a `background` hand-off).
385
+ - **FR-016** *(revised 2026-09-27)*: Any exception raised during an execution after at least one step
386
+ completed MUST roll back the completed steps, unless it is a process-termination exception
387
+ (FR-018). This includes exceptions that are not standard errors, for example a not-implemented
388
+ error, a load or syntax error from lazily loaded code, a stack overflow, or a custom exception
389
+ class. A step body that raises one of these MUST be compensated, like any step body failure.
390
+ - **FR-017**: Every failure produced under FR-013 and FR-016, and every failure whose compensation
391
+ itself failed, MUST carry the reactor name, the step name (when a step was executing), the reason
392
+ and the original exception class, with rollback failures attached as for any other failure.
393
+ - **FR-018** *(revised 2026-09-27)*: A process-termination exception MUST propagate to the caller
394
+ unchanged and MUST NOT run rollback code in the same process. Process-termination exceptions are
395
+ exactly: a signal (including an interrupt), a request to exit the process, out of memory, and the
396
+ interruption an enclosing timeout raises into the running code (it is not raised by reactor code,
397
+ and swallowing it would stop the caller's timeout from firing; research R-16). For
398
+ an inline execution, the execution MUST be recorded as **aborted** (distinct from running and
399
+ failed) while the process is still able to record it, and the existing manual undo MUST roll it
400
+ back.
401
+
402
+ #### Async step rollback hooks (F-04)
403
+
404
+ - **FR-019**: An `async_step` unit's own `compensate` MUST run in the unit's own job, exactly once,
405
+ when its body finally fails (after its last retry), whether or not any step reads the unit's
406
+ result. This mirrors how an `async_reactor` child rolls itself back. A failed attempt that is then
407
+ retried MUST NOT compensate.
408
+ - **FR-020**: An inline `undo` block declared on an `async_step` MUST be rejected when the reactor
409
+ class is defined, because nothing ever undoes an independent unit that succeeded. The message
410
+ MUST say where that cleanup belongs (the reading step's `compensate`, or an `async_reactor` child
411
+ whose steps declare `undo`). A step class that defines `undo` and is used with `async_step` MUST
412
+ produce a definition-time warning saying that `undo` will not run for this use. It is not an
413
+ error, because the same class is legitimately reused by ordinary steps (research R-09).
414
+ - **FR-021**: The outcome of an async unit's rollback, including any rollback failure, MUST be
415
+ recorded on the unit's own execution record and emitted through the existing observability events.
416
+
417
+ #### One rollback rule (F-10)
418
+
419
+ - **FR-022**: Each construct kind (step, composed reactor, map, async step, async reactor) MUST
420
+ define what its own compensate and undo do. The coordinator MUST apply one rule to all of them:
421
+ completed work is recorded for undo, work that started and failed is compensated, work that never
422
+ started is not compensated.
423
+ - **FR-023**: The documented independence of async units from their parent's rollback MUST be
424
+ expressed by the async constructs' own undo definitions, not by a coordinator special case.
425
+
426
+ #### Documentation, demo and tests
427
+
428
+ - **FR-024**: Every README.md and `./documentation` claim listed in the 007 documentation audit for
429
+ an in-scope finding MUST be corrected in the same change as its fix. The documentation MUST NOT
430
+ describe `retries` on a `compose`/`async_reactor`, `where`, `guard` or the condition error outside
431
+ the migration notes, and MUST describe the aborted status as the result of a process-termination
432
+ exception only.
433
+ - **FR-025**: Each user-visible behavior change MUST ship with a demo reactor, a listed demo rake
434
+ task and a demo spec written with the shipped matchers (Constitution VI): map rollback, a compose
435
+ whose child step retries on its own (replacing the compose retry demo), failure rollback for
436
+ argument errors and for an exception that is not a standard error, async step hooks, and a step
437
+ that skips itself by returning `Skipped` (replacing any `where`/`guard` example).
438
+ - **FR-026**: CHANGELOG.md MUST record each behavior change under the correct heading. Breaking
439
+ changes MUST include a migration note. The removal of `retries` on `compose`/`async_reactor` and
440
+ of `where`/`guard` are breaking, and each migration note MUST show the replacement.
441
+ - **FR-027**: Each invariant this feature makes hold MUST be covered by at least one automated test
442
+ against real infrastructure that fails on the pre-change behavior. The existing async step test
443
+ that passes whether or not the unit's hooks run MUST be tightened.
444
+
445
+ #### Rollback code and skipped steps (review 2026-09-27)
446
+
447
+ - **FR-028**: A `compensate` or `undo` that raises any exception other than a process-termination
448
+ exception MUST be recorded as a rollback failure for its step, and the remaining rollback MUST
449
+ continue.
450
+ - **FR-029** *(revised 2026-09-27)*: `Skipped` is an instrumentation mark (execution trace,
451
+ `skipped?`, telemetry) and MUST NOT change execution. A `Skipped` step MUST NOT be undone or
452
+ compensated: it had nothing to do.
453
+ - **FR-030**: A `background after: :x` hand-off MUST fire when `:x` returns `Skipped`, and MUST NOT
454
+ fire when `:x` returns `Halt`. A `with_period` step whose body returns `Skipped` MUST mark its
455
+ bucket.
456
+ - **FR-031**: A stored failure MUST keep at most 100 backtrace frames, so a stack overflow does not
457
+ inflate the stored context.
458
+ - **FR-032**: A resume (`continue`) MUST be accepted only while the execution is paused at an
459
+ interrupt step. A resume that arrives while the execution is running or rolling back MUST fail
460
+ without changing it. With several pending interrupts, each takes its resume once the run has
461
+ paused again. A resume for a second pending interrupt that arrives while the first resume is
462
+ still executing is rejected for now (deferred, `specs/future_improvements.md`).
463
+
464
+ ### Key Entities
465
+
466
+ - **Construct**: a unit of work in a reactor: a step, composed reactor, map, async step or async
467
+ reactor. Each defines its own forward run, compensate and undo.
468
+ - **Undo record**: what an execution keeps about a completed construct so it can undo it later
469
+ (construct, its arguments, its result). For a map this includes whatever is needed to roll back
470
+ each succeeded element.
471
+ - **Element outcome**: per map element: succeeded, failed (self-rolled-back), skipped (never
472
+ started), or in flight. Rollback coverage is decided from this.
473
+ - **Attempt**: one try of a retried step. Only steps declare retries. A nested reactor (compose,
474
+ async reactor) has exactly one run per parent step.
475
+ - **Rollback failure**: a compensate or undo that did not complete, attributed to its construct
476
+ (and element position for maps), reported on the final failure.
477
+ - **Process-termination exception**: a signal (including an interrupt), a request to exit the
478
+ process, out of memory, or an enclosing timeout's interruption. The only exceptions that skip
479
+ rollback.
480
+ - **Aborted execution**: an inline execution cut short by a process-termination exception. Its
481
+ completed work is still outstanding, and it can be found and undone manually.
482
+ - **Skipped step**: a step whose body returned `Skipped`. The run continues as for a `Success`;
483
+ the trace marks it, and it is never undone (FR-029).
484
+
485
+ ## Success Criteria *(mandatory)*
486
+
487
+ ### Measurable Outcomes
488
+
489
+ - **SC-001**: The invariants tied to in-scope findings change status to HOLDS: INV-06, INV-13,
490
+ INV-19, INV-20 and INV-24 (VIOLATED today), and INV-07 and INV-22 (CONDITIONAL today; for INV-22
491
+ the "left in place" clause, since which fan-out elements run stays scheduling-dependent). Each is
492
+ covered by at least one automated test that fails on the 0.8.3 baseline. After the review, INV-13
493
+ holds because no nested reactor can be retried as a whole (its test is the definition-time
494
+ rejection), INV-07 no longer lists `where`/`guard`, and INV-06 holds for every exception except
495
+ process-termination ones.
496
+ - **SC-002**: When the 63 scenarios of the 007 evidence set are re-run, every scenario tied to an
497
+ in-scope finding produces its corrected sequence. Every other scenario produces the same sequence
498
+ as the baseline (0 unintended changes).
499
+ - **SC-003**: Across 100 runs of a fail-fast fan-out map with randomized job order, 0 runs leave a
500
+ succeeded element without rollback.
501
+ - **SC-004**: In the 007 failure-kinds table, every failure after completed work either rolls
502
+ completed work back or, for process-termination exceptions only, leaves an execution recorded as
503
+ aborted that manual undo rolls back. 0 rows end with nothing rolled back and nothing reported.
504
+ - **SC-005**: 100% of failures produced on in-scope paths carry reactor name, step name (where a step
505
+ was executing) and reason.
506
+ - **SC-006**: A 10,000-element map can fail and roll back all its succeeded elements without a
507
+ storage size error.
508
+ - **SC-007**: Every row of the 007 documentation audit tied to an in-scope finding reads CONFIRMED
509
+ against the new behavior.
510
+ - **SC-008**: The rebuilt F-10 coverage table has no "left in place" cell for compose or map.
511
+ - **SC-009**: The full test suite, the style checks and the demo acceptance tasks pass.
512
+ - **SC-010**: Each exception kind named in FR-016 (not-implemented, load or syntax error, stack
513
+ overflow, custom exception class), raised from a step body and from an argument transform, rolls
514
+ back completed work in an automated test. 0 of them leave the execution aborted.
515
+ - **SC-011**: 0 ways remain to retry a nested reactor as a whole or to declare `where`/`guard`: each
516
+ one is rejected at definition time by an automated test, and the README and `./documentation`
517
+ mention them only in migration notes.
518
+
519
+ ## Assumptions
520
+
521
+ - **Scope**: the four High findings, plus F-05, F-06 and F-13, which the same rules fix. F-02 and
522
+ F-06 are closed by removing the constructs that caused them (compose `retries`, `where`/`guard`),
523
+ not by fixing their behavior. Out of
524
+ scope: F-07 (nested `Halt`), F-08 (manual undo outside the reactor lock), F-09 (async units running
525
+ after their dispatcher rolled back), F-11, F-12, F-14, F-15 and F-16. They stay as documented in
526
+ the 007 analysis.
527
+ - **Refactor direction**: planning evaluates the "each step owns its own lifecycle" direction and
528
+ adopts it where it makes these fixes simpler and removes construct-kind special cases from the
529
+ coordinator (FR-022, FR-023). Moving more (retries, coordination, argument preparation) into steps
530
+ is adopted only if it reduces the complexity of these fixes (Constitution V, YAGNI). The decision
531
+ and its alternatives are recorded in the planning research.
532
+ - **Async independence stays**: a parent's rollback still does not cancel or undo async units
533
+ (INV-25, documented). FR-023 changes only where that rule lives.
534
+ - **Retry ownership**: a child reactor owns the retries of its own steps. The parent never retries
535
+ a nested reactor as a whole (review 2026-09-27). `async_reactor` follows the same rule as
536
+ `compose`: its `retries` could only re-dispatch, or in inline mode re-run, the whole child.
537
+ - **`where`/`guard` removal**: they are removed, not deprecated, because they are an old path with
538
+ their own failure rules and `Skipped` covers the use. Code that relied on a condition to prevent a
539
+ `background` hand-off changes behavior and is called out in the migration note.
540
+ - **Fan-out failure latency**: waiting for elements in flight is accepted in exchange for
541
+ predictable rollback.
542
+ - **Process-termination exceptions**: running user rollback code while the process is being
543
+ signalled, is exiting or is out of memory is unsafe, so FR-018 records the execution for later
544
+ undo instead. Every other exception comes from reactor code (a body, a transform, a definition)
545
+ and is a failure of that code. Rolling it back is what the saga promises (review 2026-09-27).
546
+ - **Baseline**: commit `faf90e8d` (0.8.3 + #61, #63). The 007 evidence harness is reused as the
547
+ regression check for SC-002.
548
+ - **Versioning**: behavior changes follow Constitution V. Two are knowingly breaking and need
549
+ migration notes: element-step `undo` blocks now run when a map is rolled back (FR-006), and
550
+ `undo` on an `async_step` is now rejected (FR-020). An `async_step`'s `compensate` that never ran
551
+ before now runs (FR-019). The review adds three more breaking changes: `retries` on
552
+ `compose`/`async_reactor` is rejected (FR-009), `where`/`guard` are removed (FR-014), and
553
+ exceptions that are not standard errors now roll back instead of propagating (FR-016). Planning
554
+ decides the SemVer level of each change.
555
+ - **Deferred by the review (2026-09-27)**, recorded in `specs/future_improvements.md`: a resume for
556
+ a second pending interrupt while the first executes; re-running an interrupted `compensate` of
557
+ the failing step on manual undo; two resumes in the same instant (accepted).
558
+ - **Revision of existing work**: the first implementation of this feature is already on the branch.
559
+ Planning updates the research decisions this revision reverses (R-05 fresh child per attempt,
560
+ R-06 condition errors, R-08 aborted on every non-standard exception) and the tasks that
561
+ implemented them.