ruby_reactor 0.7.1 → 0.8.0

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +26 -16
  5. data/.specify/templates/plan-template.md +4 -0
  6. data/.specify/templates/tasks-template.md +1 -1
  7. data/CHANGELOG.md +140 -0
  8. data/CLAUDE.md +1 -1
  9. data/README.md +125 -31
  10. data/lib/ruby_reactor/context.rb +2 -2
  11. data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
  12. data/lib/ruby_reactor/dsl/reactor.rb +36 -18
  13. data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
  14. data/lib/ruby_reactor/dsl/template_helpers.rb +2 -2
  15. data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
  16. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  17. data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
  18. data/lib/ruby_reactor/executor/result_handler.rb +9 -3
  19. data/lib/ruby_reactor/executor/retry_manager.rb +2 -1
  20. data/lib/ruby_reactor/executor/step_executor.rb +9 -2
  21. data/lib/ruby_reactor/executor.rb +3 -0
  22. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
  23. data/lib/ruby_reactor/reactor.rb +9 -12
  24. data/lib/ruby_reactor/rspec/matchers.rb +3 -6
  25. data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
  26. data/lib/ruby_reactor/step/compose_step.rb +56 -75
  27. data/lib/ruby_reactor/step/input_contract.rb +128 -0
  28. data/lib/ruby_reactor/step/map_step.rb +177 -218
  29. data/lib/ruby_reactor/step.rb +116 -21
  30. data/lib/ruby_reactor/step_signals.rb +6 -2
  31. data/lib/ruby_reactor/step_worker.rb +25 -10
  32. data/lib/ruby_reactor/template/result.rb +9 -2
  33. data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
  34. data/lib/ruby_reactor/version.rb +1 -1
  35. data/lib/ruby_reactor.rb +5 -2
  36. data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
  37. data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
  38. data/specs/002-step-input-contracts/data-model.md +115 -0
  39. data/specs/002-step-input-contracts/plan.md +165 -0
  40. data/specs/002-step-input-contracts/quickstart.md +170 -0
  41. data/specs/002-step-input-contracts/research.md +233 -0
  42. data/specs/002-step-input-contracts/spec.md +359 -0
  43. data/specs/002-step-input-contracts/tasks.md +367 -0
  44. data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
  45. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
  46. data/specs/004-inheritable-step-class/data-model.md +116 -0
  47. data/specs/004-inheritable-step-class/plan.md +174 -0
  48. data/specs/004-inheritable-step-class/quickstart.md +112 -0
  49. data/specs/004-inheritable-step-class/research.md +308 -0
  50. data/specs/004-inheritable-step-class/spec.md +316 -0
  51. data/specs/004-inheritable-step-class/tasks.md +258 -0
  52. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
  53. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
  54. data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
  55. data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
  56. data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
  57. data/specs/deferred-003-step-lock-declarations/research.md +196 -0
  58. data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
  59. data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
  60. data/specs/possible_feature.md +22 -0
  61. metadata +28 -1
@@ -0,0 +1,196 @@
1
+ # Phase 0 Research: Step-Scoped Coordination
2
+
3
+ **Feature**: `specs/003-step-lock-declarations/` | **Date**: 2026-09-10
4
+
5
+ Findings come from reading the current implementation. File references are to the state of
6
+ `step_validations` at the time of writing.
7
+
8
+ ## Current state
9
+
10
+ | Concern | Where it lives |
11
+ |---|---|
12
+ | Declaration DSL | `Dsl::Lockable::ClassMethods` — `with_lock`, `with_semaphore`, `with_rate_limit`, `with_period`, `with_ordered_lock`. Included into `Reactor` only. |
13
+ | Acquisition | `Executor#acquire_locks` → `check_rate_limit`, `acquire_exclusive_lock`, `acquire_semaphore` (`executor.rb:350-360`) |
14
+ | Key derivation | `config[:key_proc].call(@context.inputs)` — reactor inputs |
15
+ | Owner | root context id (`executor.rb:501`) — the basis of re-entrancy |
16
+ | Held-key registry | `root.private_data[:held_lock_keys]` |
17
+ | Deadlock guard | `Step::AsyncReactorStep.detect_lock_deadlock` (`async_reactor_step.rb:65`) — refuses dispatch when the child declares a key the parent holds |
18
+ | Park / resume | `park_held_primitives!` + `consume_parked_primitives!`; `Lock#detach` / `#reattach` |
19
+ | Contention split | `Executor#contention_wait` (`executor.rb:566`) — `0` inside a worker (snooze instead of blocking), configured wait otherwise |
20
+ | Step retry requeue | `RetryManager#requeue_job_for_step_retry` — sets `current_step`, persists root context, `perform_in(delay, …)`, returns `RetryQueuedResult` |
21
+ | Ordered lock | `Executor::OrderedLockSupport` — nonce assigned at enqueue in `Reactor#run`, stashed in `private_data[:ordered_lock]`, gate at execute/resume, advance on terminal reactor result, heartbeat thread |
22
+ | Compensation | `CompensationManager#compensate_step` / `#undo_step` |
23
+ | Dashboard | `Web::CoordinationSerializer` — reads `reactor_class.lock_config` etc. |
24
+
25
+ ### Finding 1 — the DSL needs no redesign, only a second host
26
+
27
+ `Dsl::Lockable::ClassMethods` is already a self-contained module of five macros whose only
28
+ contract is "a key proc that receives a hash". Reactor passes `context.inputs`; a step would
29
+ pass its resolved arguments. The declaration surface is reusable as-is, including its
30
+ `inherited` hook for subclass propagation.
31
+
32
+ ### Finding 2 — `contention_wait` already encodes the sync/worker split the spec asks for
33
+
34
+ FR-015/FR-016's split is not new policy: `contention_wait` returns `0` inside a worker so the
35
+ job snoozes rather than blocking a thread, and the configured wait outside. Step-level
36
+ coordination gets the correct behavior on both paths by reusing it.
37
+
38
+ ### Finding 3 — parking an execution at a step already exists
39
+
40
+ `requeue_job_for_step_retry` persists the root context with `current_step` set and enqueues a
41
+ delayed job; the redelivery resumes at that step. It is reached today only from retry-on-
42
+ failure, but nothing in it is failure-specific. Step contention can reuse it verbatim with a
43
+ contention delay, which is why FR-015 ("park, don't fail") costs one call rather than a new
44
+ mechanism.
45
+
46
+ ### Finding 4 — the deadlock guard is keyed on the registry, not on reactors
47
+
48
+ `detect_lock_deadlock` reads `held_lock_keys` from the root context and compares against the
49
+ *child's* declared keys. Because step-held keys will land in the same registry, the existing
50
+ guard covers "a step holds K, its body dispatches an async_reactor that wants K" with no
51
+ change. It needs extending only to know about `async_step` dispatch, where the dispatched
52
+ step class may itself declare K.
53
+
54
+ ### Finding 5 — ordered lock is structurally reactor-shaped
55
+
56
+ The nonce is assigned in `Reactor#run` at enqueue time, before any step exists, and the
57
+ advance fires on the *reactor's* terminal result. Its guarantee is "executions run in the
58
+ order they were enqueued". A step-level equivalent cannot assign at enqueue, because the key
59
+ expression reads arguments that are not resolved until the step is reached — so its guarantee
60
+ degrades to "in the order executions reached this step", which is a materially weaker promise.
61
+ See D8; this is the one primitive whose step-scoped meaning is not a simple narrowing.
62
+
63
+ ### Finding 6 — a step holding coordination across a park is nearly unreachable
64
+
65
+ Argument resolution (including any blocking wait on an async result) happens *before*
66
+ acquisition, and a step whose body is dispatched elsewhere never acquires in the dispatching
67
+ process. The one construct that could hold coordination across a park is an interrupt step,
68
+ whose body is split across a pause. See D6.
69
+
70
+ ---
71
+
72
+ ## Decisions
73
+
74
+ ### D1 — Steps host the existing `Lockable` macros unchanged
75
+
76
+ `RubyReactor::Step::ClassMethods` gains the same five macros by reusing
77
+ `Dsl::Lockable::ClassMethods`; `Dsl::StepBuilder` gains them for inline steps. The key proc
78
+ receives the step's resolved arguments instead of reactor inputs.
79
+
80
+ **Rationale**: Finding 1. One declaration surface, one set of option semantics, one place to
81
+ document. Authors already know the macros.
82
+
83
+ **Alternatives rejected**: a parallel `step_lock` vocabulary — two names for one concept.
84
+
85
+ ### D2 — Enforcement is driven by the executor, not by a wrapper on the step
86
+
87
+ A single `StepCoordination` object wraps the step body. The executor drives it, because three
88
+ requirements are outside a step class's reach: parking the execution on contention (needs the
89
+ requeue path), skipping acquisition for a guard-suppressed step, and re-taking coordination
90
+ during rollback.
91
+
92
+ FR-023 (direct invocation) is served by the same object, entered with `park: false` — with no
93
+ execution to park, contention waits and then fails, exactly as the synchronous path does.
94
+
95
+ **Rationale**: this deliberately differs from `002`'s decision to enforce input contracts in a
96
+ prepended `run`. Validation is a pure function of the arguments; coordination is a property of
97
+ the execution — it parks it, releases it, and must survive into rollback. The two belong at
98
+ different layers, and saying so explicitly is cheaper than discovering it later.
99
+
100
+ ### D3 — Fixed acquisition order, mirroring the reactor's
101
+
102
+ 1. Ordered-lock gate (nothing else held while waiting for a turn — the existing hold-and-wait
103
+ guard in `OrderedLockSupport`)
104
+ 2. Period gate, fast path
105
+ 3. Rate limit
106
+ 4. Exclusive lock
107
+ 5. Semaphore
108
+ 6. Period gate, re-check under the lock (closes the both-passed race, same as
109
+ `executor.rb:113`)
110
+
111
+ Released in reverse. Acquisition happens after guards and after argument validation — a step
112
+ that will fail validation must not first take a lock (FR-012, and it keeps the critical
113
+ section minimal).
114
+
115
+ ### D4 — Contention parks via the step-retry requeue path
116
+
117
+ On `Lock::AcquisitionError`, `Semaphore::AcquisitionError`, `RateLimit::ExceededError`, or
118
+ `OrderedLock::WaitError`:
119
+
120
+ - **In a worker** (`context.inline_async_execution`): call the requeue path with a contention
121
+ delay and return `RetryQueuedResult`. The delay uses the primitive's own hint where it has
122
+ one (`retry_after_seconds` for rate limits) and a configured contention backoff otherwise.
123
+ - **Synchronously**: `contention_wait` has already blocked for the configured wait, so the
124
+ error propagates as an ordinary step failure and rollback proceeds.
125
+
126
+ Contention attempts are counted separately from failure retries (`retry_context` gains a
127
+ contention counter), bounded by a configurable ceiling; exceeding it converts the park into a
128
+ contention failure (FR-017). Counting contention against the step's `retries` budget would let
129
+ a busy key exhaust the retries meant for genuine failures.
130
+
131
+ **Alternatives rejected**: failing on both paths — the user's chosen behavior is park-and-
132
+ retry; the sync fallback exists only because there is no queue to park into.
133
+
134
+ ### D5 — Re-entrancy reuses every existing primitive verbatim
135
+
136
+ - **Owner** is the root context id, so a step's hold nests inside its reactor's hold on the
137
+ same key and inside any ancestor's (FR-019).
138
+ - **Nesting count** is the adapter's existing re-entrancy count; the key frees at zero (FR-020).
139
+ - **Registry**: step-held keys are pushed to and popped from `root.private_data[:held_lock_keys]`
140
+ exactly as reactor-held keys are (FR-021).
141
+ - **Deadlock guard**: covered for `async_reactor` with no change (Finding 4); extended so
142
+ `async_step` dispatch also checks the dispatched step class's declared keys against the
143
+ registry (FR-022).
144
+ - **Direct invocation** has no context, so the owner is a per-call UUID and no re-entrancy
145
+ applies.
146
+
147
+ ### D6 — Coordination on an interrupt step is refused at declaration
148
+
149
+ An interrupt step's body is split across a pause, so it is the one construct where a
150
+ step-level hold could span a park (Finding 6). Rather than extend `park_held_primitives!` to
151
+ carry step-level holds across gaps — new state, new reattach path, new failure mode — v1
152
+ raises at declaration time with a message pointing at reactor-level coordination for that case.
153
+
154
+ Everything FR-018 requires of parked executions continues to work: it describes coordination
155
+ the *execution* holds, which is reactor-level and unchanged.
156
+
157
+ ### D7 — Step-level period skips the step
158
+
159
+ Reactor-level `with_period` halts the whole reactor when the bucket is already marked. At step
160
+ level that would kill workflows over one deduplicated step, so the step returns `Skipped` and
161
+ the workflow continues (FR-003). The bucket is marked when the step's work succeeds.
162
+
163
+ ### D8 — Step-level ordered lock: nonce assigned on first arrival, sequenced last
164
+
165
+ The nonce is assigned when an execution first reaches the step, stashed per-step in
166
+ `private_data`, and reused across contention redeliveries. The gate advances when the step
167
+ reaches a terminal result. Strict chain failure short-circuits that step with `Skipped` rather
168
+ than halting the reactor (FR-004).
169
+
170
+ **Honest caveat**: per Finding 5 this delivers "ordered by arrival at the step", not "ordered
171
+ by enqueue". For a reactor whose first step is the ordered one, the two coincide; the further
172
+ into a workflow the step sits, the weaker the guarantee. This must be documented on the macro
173
+ itself, not just in a spec.
174
+
175
+ **Sequencing**: this primitive is roughly the same implementation weight as the other four
176
+ combined — per-step nonce state, per-step heartbeat, per-step advance-on-terminal, poison-pill
177
+ and strict-chain handling at step granularity. It is the last phase, and it is the piece to
178
+ cut first if the schedule tightens: phases 1-6 deliver the whole of US1-US4 and US6-US8 without
179
+ it. Recorded in Complexity Tracking.
180
+
181
+ ### D9 — Observability extends the existing surfaces
182
+
183
+ Middleware events gain the step name; `Web::CoordinationSerializer` learns to read step-level
184
+ configs alongside reactor-level ones; a contention-parked execution is reported distinctly
185
+ from a failure, reusing the `:snooze_reactor` precedent (`executor.rb:166`) so a snooze round
186
+ does not appear as a phantom failure.
187
+
188
+ ## Open risks
189
+
190
+ | Risk | Mitigation |
191
+ |---|---|
192
+ | Contention behaves differently sync vs. worker | Inherent to the chosen behavior; `contention_wait` makes it one branch, and it is called out in the macro's own documentation. |
193
+ | A busy key snoozes an execution indefinitely | Bounded contention counter (D4), separate from the failure-retry budget. |
194
+ | Step-level ordered lock's weaker guarantee is mistaken for the reactor-level one | Documented on the macro; demo shows arrival-order explicitly. |
195
+ | Lock TTL shorter than a slow step's work | Auto-extend applies to step holds exactly as to reactor holds. |
196
+ | Compensation stalls on a contended key | Compensation waits then reports (FR-026); it never parks, because rollback is already mid-failure. |
@@ -0,0 +1,447 @@
1
+ # Feature Specification: Step-Scoped Coordination
2
+
3
+ **Feature Branch**: `step_validations`
4
+
5
+ **Created**: 2026-09-10
6
+
7
+ **Status**: Draft
8
+
9
+ **Input**: User description: "One more feature to added to steps: Locks should be able to be declared in the class steps
10
+
11
+ ```ruby
12
+ class MyStep
13
+ include RubyReactor::Step
14
+ input :id
15
+ with_lock { |i| "k:#{i[:id]}" }
16
+ end
17
+ ```
18
+
19
+ It should follow the same reentry primitives as the nested reactors do."
20
+
21
+ ## User Scenarios & Testing *(mandatory)*
22
+
23
+ ### User Story 1 - A step class declares the lock it needs (Priority: P1)
24
+
25
+ A workflow author writes a step that must not run concurrently with another execution of
26
+ itself for the same subject — charging one account, updating one inventory row, syncing one
27
+ external record. Today the only place to say that is the whole reactor, which locks far more
28
+ than the step needs and forces the key to be derived from reactor inputs rather than from the
29
+ step's own values.
30
+
31
+ The author declares the lock inside the step class, next to the inputs it is keyed on. The
32
+ lock is taken immediately before the step's work begins and released as soon as the step
33
+ finishes.
34
+
35
+ **Why this priority**: This is the feature. Without it, "lock this one step" is expressible
36
+ only by locking the entire reactor.
37
+
38
+ **Independent Test**: Define a step class with a declared lock keyed on one of its inputs,
39
+ run two reactors concurrently with the same key value, and confirm the step bodies never
40
+ overlap; run two with different key values and confirm they do overlap.
41
+
42
+ **Acceptance Scenarios**:
43
+
44
+ 1. **Given** a step class declaring a lock keyed on one of its inputs, **When** two executions
45
+ with the same key value run concurrently, **Then** the second cannot enter the step's work
46
+ until the first has left it.
47
+ 2. **Given** the same step class, **When** two executions with different key values run
48
+ concurrently, **Then** both enter the step's work at the same time.
49
+ 3. **Given** a step holding a declared lock, **When** the step's work succeeds, **Then** the
50
+ lock is released before the next step begins.
51
+ 4. **Given** a step holding a declared lock, **When** the step's work raises or returns a
52
+ failure, **Then** the lock is released rather than held until it expires.
53
+ 5. **Given** a step whose lock key derives from an input, **When** the reactor supplies that
54
+ input, **Then** the key is computed from the step's own resolved values, not from the
55
+ reactor's inputs.
56
+
57
+ ---
58
+
59
+ ### User Story 2 - Only the step is locked, not the whole workflow (Priority: P1)
60
+
61
+ An author has a reactor where one step out of eight needs exclusivity. Locking the reactor
62
+ serializes all eight and holds the lock across slow, lock-irrelevant work. With a
63
+ step-scoped lock, the other seven steps of two concurrent executions run in parallel and only
64
+ the one contended step serializes.
65
+
66
+ **Why this priority**: This is the value the feature delivers over what exists. Without it,
67
+ the declaration has moved but the behavior has not improved.
68
+
69
+ **Independent Test**: Run two executions of an eight-step reactor whose third step declares a
70
+ lock on a shared key; confirm the first two steps of both run concurrently and only the third
71
+ serializes.
72
+
73
+ **Acceptance Scenarios**:
74
+
75
+ 1. **Given** a reactor where one step declares a lock, **When** two executions run
76
+ concurrently with the same key, **Then** only that step serializes; the surrounding steps
77
+ overlap.
78
+ 2. **Given** the same reactor, **When** one execution is waiting on the step's coordination,
79
+ **Then** the other execution's unrelated steps are not blocked by that wait.
80
+
81
+ ---
82
+
83
+ ### User Story 3 - Contention parks the execution instead of failing it (Priority: P1)
84
+
85
+ Two executions reach the same locked step at the same time. The loser does not fail — its
86
+ work has not been attempted and nothing needs compensating. When the execution is running in
87
+ a worker, it steps aside and is retried later, the way an out-of-turn ordered execution
88
+ already snoozes today. The workflow completes; it simply completes later.
89
+
90
+ Running synchronously in the calling process there is no queue to step aside into. There, the
91
+ step waits up to its configured wait and then fails with a contention error naming the step
92
+ and the key.
93
+
94
+ **Why this priority**: Contention is the normal case a lock exists to handle. Turning routine
95
+ contention into failure-plus-rollback would make step locks unusable for the workloads that
96
+ most need them.
97
+
98
+ **Independent Test**: Run two worker-backed executions against the same key and confirm both
99
+ eventually complete successfully, the second after the first released; then run the same pair
100
+ synchronously and confirm the loser fails with a contention error.
101
+
102
+ **Acceptance Scenarios**:
103
+
104
+ 1. **Given** two worker-backed executions contending for one step's key, **When** the loser
105
+ cannot take the key, **Then** it is retried later and eventually completes successfully.
106
+ 2. **Given** the loser is parked for a later attempt, **When** it parks, **Then** no step of
107
+ that execution has been compensated and no side effect of the contended step has occurred.
108
+ 3. **Given** a parked execution holds other coordination for the same execution, **When** it
109
+ parks and later resumes, **Then** it keeps ownership across the gap and resumes without
110
+ re-competing for what it already held.
111
+ 4. **Given** a synchronous in-process execution, **When** it cannot take the key within its
112
+ configured wait, **Then** the step fails with a contention error naming the reactor, step,
113
+ and key, and prior steps compensate as any step failure does.
114
+ 5. **Given** repeated contention, **When** an execution is retried more times than its
115
+ configured ceiling, **Then** it stops being retried and reports the contention rather than
116
+ snoozing forever.
117
+
118
+ ---
119
+
120
+ ### User Story 4 - Re-entrancy behaves exactly as nested workflows already do (Priority: P1)
121
+
122
+ An execution never blocks on coordination it already holds. A step keyed the same as its own
123
+ reactor proceeds; nested work started inside a locked step proceeds; a key is released for
124
+ other executions only once the outermost holder within the execution is done with it.
125
+
126
+ Where ownership genuinely cannot be shared — work handed to another process, which runs
127
+ concurrently rather than within the holder — the system refuses at hand-off time with an
128
+ actionable message instead of letting the two sides wait on each other forever.
129
+
130
+ **Why this priority**: Coordination that deadlocks against itself is worse than no
131
+ coordination. The existing rules for nested workflows are the rules; step locks must not
132
+ introduce a second, different set.
133
+
134
+ **Independent Test**: Build a reactor that locks a key, containing a step that locks the same
135
+ key, containing nested work that locks it again; confirm it completes. Then hand the nested
136
+ work to another process and confirm the hand-off is refused with a message naming the key.
137
+
138
+ **Acceptance Scenarios**:
139
+
140
+ 1. **Given** a reactor holding a key, **When** a step inside it declares the same key,
141
+ **Then** the step proceeds without waiting.
142
+ 2. **Given** a step holding a key, **When** its work starts nested work declaring the same
143
+ key, **Then** the nested work proceeds without waiting.
144
+ 3. **Given** nested holds on one key within one execution, **When** the inner holds are
145
+ released, **Then** the key stays unavailable to other executions until the outermost hold
146
+ is released.
147
+ 4. **Given** an execution holding a key, **When** it tries to hand work declaring that key to
148
+ another process, **Then** the hand-off is refused before dispatch with a message naming
149
+ the key, the holder, and how to restructure.
150
+ 5. **Given** a step whose work is handed to a worker, **When** the work runs there, **Then**
151
+ the key is taken by that worker — never taken in the dispatching process and carried
152
+ across.
153
+ 6. **Given** an execution that parks mid-flight while holding coordination, **When** it
154
+ resumes, **Then** it re-adopts what it held without a duplicate acquisition being recorded,
155
+ and falls back to competing normally if the hold lapsed while parked.
156
+
157
+ ---
158
+
159
+ ### User Story 5 - The whole coordination family is available per step (Priority: P2)
160
+
161
+ Everything a reactor can declare about coordination, a step can declare about itself:
162
+ exclusivity, a concurrency ceiling, a rate ceiling, once-per-window deduplication, and strict
163
+ ordering. Each keeps the meaning it has at reactor level, narrowed to the step.
164
+
165
+ **Why this priority**: Parity is what makes the step a real unit of work rather than a
166
+ partial one; but exclusivity alone already delivers the primary value.
167
+
168
+ **Independent Test**: Declare each primitive on a step in turn and confirm the step-scoped
169
+ behavior matches the reactor-scoped behavior narrowed to that step.
170
+
171
+ **Acceptance Scenarios**:
172
+
173
+ 1. **Given** a step declaring a concurrency ceiling of N for a key, **When** more than N
174
+ executions reach it, **Then** at most N are inside the step's work at once and the rest
175
+ contend as US3 describes.
176
+ 2. **Given** a step declaring a rate ceiling, **When** the ceiling is reached, **Then**
177
+ further executions of that step contend as US3 describes rather than exceeding the rate.
178
+ 3. **Given** a step declaring once-per-window deduplication, **When** a second execution
179
+ reaches it in the same window with the same key, **Then** that **step** is skipped and the
180
+ rest of the workflow continues — the reactor is not halted.
181
+ 4. **Given** a step declaring strict ordering, **When** executions reach it out of order,
182
+ **Then** each waits until its turn, and the surrounding steps are unaffected.
183
+ 5. **Given** a step declaring strict ordering with stop-the-line behavior, **When** an earlier
184
+ position in the sequence ends in failure, **Then** later positions short-circuit at that
185
+ step rather than executing it.
186
+
187
+ ---
188
+
189
+ ### User Story 6 - Coordination is re-taken to undo the work it protected (Priority: P2)
190
+
191
+ A locked step succeeded; a later step failed; rollback reaches the locked step. The
192
+ compensating work touches the same resource the forward work did, so it runs under the same
193
+ exclusivity — a refund never races another execution's charge on the same key.
194
+
195
+ **Why this priority**: Without it, the lock protects the forward path and abandons the
196
+ rollback path, which is where correctness problems are hardest to see.
197
+
198
+ **Independent Test**: Fail a reactor after a locked step succeeded; confirm the compensation
199
+ of that step holds the same key, and that a concurrent execution cannot enter the step's
200
+ forward work while the compensation runs.
201
+
202
+ **Acceptance Scenarios**:
203
+
204
+ 1. **Given** a step that declared exclusivity and succeeded, **When** rollback compensates it,
205
+ **Then** the compensation runs holding the same key, computed from the same values.
206
+ 2. **Given** that compensation is running, **When** another execution reaches the same step
207
+ with the same key, **Then** it cannot enter until the compensation has released the key.
208
+ 3. **Given** a step declaring a rate ceiling or a deduplication window, **When** it is
209
+ compensated, **Then** the compensation is not gated by those — cleanup is never suppressed
210
+ by a forward-work quota.
211
+ 4. **Given** compensation cannot take the key, **When** the wait expires, **Then** the
212
+ rollback reports it rather than silently skipping the compensation.
213
+
214
+ ---
215
+
216
+ ### User Story 7 - Operators can see step coordination (Priority: P2)
217
+
218
+ An operator debugging a stalled workflow needs to know which step is waiting on which key,
219
+ and which execution holds it. Step coordination appears in the same surfaces reactor-level
220
+ coordination already does — logs, failure records, and the dashboard's coordination view.
221
+
222
+ **Why this priority**: Required by the project's observability commitments; the feature
223
+ functions without it.
224
+
225
+ **Independent Test**: Start a long-held step lock, inspect the dashboard's coordination view
226
+ and the logs for the waiting execution, and confirm the step, key, and holder are
227
+ identifiable.
228
+
229
+ **Acceptance Scenarios**:
230
+
231
+ 1. **Given** a step holding coordination, **When** an operator inspects the running execution,
232
+ **Then** the key, the owning step, and the holder are visible.
233
+ 2. **Given** a step that could not take its key, **When** the outcome is inspected, **Then**
234
+ it names the reactor, the step, and the key.
235
+ 3. **Given** coordination is taken and released, **When** instrumentation is enabled, **Then**
236
+ acquisition, release, and failure are observable as distinct events attributed to the step.
237
+ 4. **Given** an execution parked by contention, **When** an operator inspects it, **Then** it
238
+ is distinguishable from a failed execution and shows what it is waiting on.
239
+
240
+ ---
241
+
242
+ ### User Story 8 - Inline steps can declare coordination too (Priority: P3)
243
+
244
+ An author writing a short inline step declares its coordination in the step block, using the
245
+ same words a step class uses, so moving the step into a class later is a copy rather than a
246
+ rewrite.
247
+
248
+ **Why this priority**: Consistency; class steps are the project's preferred style, so this is
249
+ a completeness item.
250
+
251
+ **Independent Test**: Declare a lock on an inline step, confirm the same behavior as the class
252
+ form, then move it into a class unchanged.
253
+
254
+ **Acceptance Scenarios**:
255
+
256
+ 1. **Given** an inline step declaring a lock, **When** two executions with the same key run,
257
+ **Then** the behavior matches the class form exactly.
258
+
259
+ ---
260
+
261
+ ### Edge Cases
262
+
263
+ - The key expression raises, or returns an unusable value (nil, empty): the step fails before
264
+ the work runs, naming the step and the cause. Work is never run unprotected because its key
265
+ could not be computed.
266
+ - The step's work outlives the coordination's expiry: the hold is kept alive while the work
267
+ runs, so a slow step does not silently lose exclusivity mid-flight.
268
+ - The holding process crashes while the step is running: the hold expires on its own so the
269
+ key does not stay locked forever, and the next execution proceeds.
270
+ - The step is skipped by a condition or guard: nothing is taken for work that never runs.
271
+ - Two steps in one reactor declare the same key: the second takes it after the first released
272
+ it; they do not deadlock, because both holds belong to the same execution.
273
+ - A step declares coordination and its work is handed to another process: the hold is taken in
274
+ that process, and the hand-off is refused up front if the dispatching execution already
275
+ holds the key (US4 scenario 4).
276
+ - An execution parks at an interrupt while a step's hold is live: the hold is kept through the
277
+ gap bounded by its expiry and re-adopted on resume, without recording a second acquisition.
278
+ - Repeated contention: retries are bounded, and an execution that never wins reports the
279
+ contention rather than snoozing indefinitely.
280
+ - The coordination backing store is unreachable: the step fails with a clear cause rather than
281
+ proceeding unprotected.
282
+ - A step declares more than one primitive: they are taken in a fixed, documented order and
283
+ released in reverse, so two steps declaring the same pair can never deadlock against each
284
+ other.
285
+
286
+ ## Requirements *(mandatory)*
287
+
288
+ ### Functional Requirements
289
+
290
+ #### Declaration
291
+
292
+ - **FR-001**: A step class MUST be able to declare coordination for itself, with keys derived
293
+ from the step's own resolved argument values.
294
+ - **FR-002**: The full coordination family MUST be declarable at step level — exclusivity, a
295
+ concurrency ceiling, a rate ceiling, once-per-window deduplication, and strict ordering —
296
+ each keeping its reactor-level meaning narrowed to the step.
297
+ - **FR-003**: Once-per-window deduplication at step level MUST skip the **step** and let the
298
+ workflow continue, rather than halting the reactor as the reactor-level form does.
299
+ - **FR-004**: Strict ordering at step level MUST sequence executions at that step only, and
300
+ its stop-the-line behavior MUST short-circuit that step for later positions rather than the
301
+ whole workflow.
302
+ - **FR-005**: An inline step MUST be able to declare coordination with the same vocabulary a
303
+ step class uses, with identical behavior.
304
+ - **FR-006**: Declarations MUST be introspectable, so tooling and operational views can report
305
+ which steps coordinate and on what keys.
306
+ - **FR-007**: A key that cannot be computed MUST fail the step before its work runs, naming
307
+ the step and the cause.
308
+ - **FR-008**: A step declaring multiple primitives MUST take them in a fixed, documented order
309
+ and release them in reverse.
310
+
311
+ #### Scope and lifecycle
312
+
313
+ - **FR-009**: Coordination MUST be taken immediately before the step's work begins and
314
+ released when the step finishes — on success, failure, or unexpected error.
315
+ - **FR-010**: Coordination MUST be taken in whichever process performs the step's work,
316
+ including background workers, retried attempts, and runs resumed after an interrupt. It MUST
317
+ NOT be held in a process that is only dispatching work elsewhere.
318
+ - **FR-011**: A step-scoped hold MUST NOT serialize the steps around it — concurrent
319
+ executions MUST continue to overlap on every step that does not share the key.
320
+ - **FR-012**: Nothing MUST be taken for a step that a condition or guard prevents from running.
321
+ - **FR-013**: A hold MUST be kept alive while its step's work is still running, and MUST expire
322
+ on its own if the holding process dies.
323
+ - **FR-014**: Declaring coordination MUST NOT change which steps run or in what order; it
324
+ changes only when a step may begin.
325
+
326
+ #### Contention
327
+
328
+ - **FR-015**: When an execution running in a worker cannot take a step's coordination within
329
+ its configured wait, the execution MUST be parked and retried later rather than failed. No
330
+ step MUST be compensated and no side effect of the contended step MUST have occurred.
331
+ - **FR-016**: When an execution running synchronously in the calling process cannot take a
332
+ step's coordination within its configured wait, the step MUST fail with a contention error
333
+ naming the reactor, step, and key, and rollback MUST proceed as for any step failure.
334
+ - **FR-017**: Retries caused by contention MUST be bounded; an execution exceeding the ceiling
335
+ MUST report the contention rather than being retried indefinitely.
336
+ - **FR-018**: A parked execution MUST keep ownership of coordination it already holds across
337
+ the gap and re-adopt it on resume without recording a duplicate acquisition, falling back to
338
+ competing normally if the hold lapsed while parked.
339
+
340
+ #### Re-entrancy
341
+
342
+ - **FR-019**: Holds MUST be owned by the execution, not by the individual step or reactor, so
343
+ that any work within one execution proceeds on a key that execution already holds.
344
+ - **FR-020**: Nested holds on one key within one execution MUST be counted, and the key MUST
345
+ remain unavailable to other executions until the outermost hold is released.
346
+ - **FR-021**: The keys an execution currently holds MUST be tracked for the execution as a
347
+ whole, so that hand-off decisions and operational views can see them.
348
+ - **FR-022**: Ownership MUST NOT be shared across a hand-off to another process. Handing off
349
+ work that declares a key the dispatching execution currently holds MUST be refused before
350
+ dispatch, with a message naming the key, the holder, and how to restructure.
351
+ - **FR-023**: A step's declared coordination MUST be honored when the step class is invoked
352
+ directly, not only when a reactor executes it.
353
+
354
+ #### Rollback
355
+
356
+ - **FR-024**: Exclusivity and concurrency ceilings declared by a step MUST be re-taken for that
357
+ step's compensation and undo, using the same key computed from the same values.
358
+ - **FR-025**: Rate ceilings and deduplication windows MUST NOT gate compensation or undo —
359
+ cleanup MUST never be suppressed by a forward-work quota.
360
+ - **FR-026**: Compensation that cannot take its key within the configured wait MUST be reported
361
+ rather than silently skipped.
362
+
363
+ #### Compatibility and delivery
364
+
365
+ - **FR-027**: Reactor-level coordination declarations MUST continue to work unchanged; step
366
+ level is additive and independent.
367
+ - **FR-028**: Acquisition, release, and acquisition failure MUST be observable as distinct
368
+ events attributed to the step, carrying the key.
369
+ - **FR-029**: Step-level coordination MUST appear in the operational views that already show
370
+ reactor-level coordination state, identified by its step, and a contention-parked execution
371
+ MUST be distinguishable from a failed one.
372
+ - **FR-030**: The feature MUST ship a runnable demo reactor, a demo task, and a spec using only
373
+ the shipped test surface, demonstrating the serialized path, the contention path, and the
374
+ compensation path.
375
+ - **FR-031**: Documentation MUST show the step-scoped forms, state when to prefer them over
376
+ reactor-level declarations, and describe the contention outcome on both execution paths.
377
+
378
+ ### Key Entities *(include if data involved)*
379
+
380
+ - **Step Coordination Declaration**: what a unit of work declares about when it may run.
381
+ Attributes: primitive kind, key expression (evaluated against the step's resolved
382
+ arguments), limits, expiry, wait tolerance, keep-alive. Owned by exactly one step.
383
+ - **Hold**: the runtime fact that one execution holds one key. Attributes: key, owning
384
+ execution, owning step, nesting count, acquired-at, expiry. Ends when the outermost hold is
385
+ released or the expiry lapses.
386
+ - **Held-Key Registry**: the set of keys an execution currently holds, tracked for the
387
+ execution as a whole and consulted when work is handed to another process.
388
+ - **Contention Outcome**: what an execution gets when it cannot take a key in time — a parked
389
+ execution scheduled for a later attempt, or a contention failure, depending on whether it is
390
+ running in a worker or synchronously.
391
+
392
+ ## Success Criteria *(mandatory)*
393
+
394
+ ### Measurable Outcomes
395
+
396
+ - **SC-001**: Two concurrent executions of a reactor whose step declares the same key never
397
+ overlap inside that step's work — 0 overlapping entries across a sustained concurrent run.
398
+ - **SC-002**: In the same run, every step that does not share the key overlaps freely; total
399
+ wall-clock time is bounded by the contended step alone, not by the whole workflow.
400
+ - **SC-003**: Coordination is released within one step boundary of the step finishing, in 100%
401
+ of outcomes including failures and unexpected errors.
402
+ - **SC-004**: 100% of worker-backed executions that lose contention still complete
403
+ successfully on a later attempt, with zero compensations triggered by the contention itself.
404
+ - **SC-005**: A step whose work runs in a background worker is protected identically to one
405
+ that runs in the calling process — the same concurrency test passes on both paths.
406
+ - **SC-006**: A nested arrangement holding one key at reactor, step, and nested-work level
407
+ completes without waiting on itself, and the key becomes available to other executions only
408
+ after the outermost release.
409
+ - **SC-007**: 100% of hand-offs that would deadlock on a held key are refused at dispatch with
410
+ a message naming the key — none are allowed to wait indefinitely.
411
+ - **SC-008**: A killed process holding step coordination leaves the key available again
412
+ without operator action.
413
+ - **SC-009**: A compensation of a step that declared exclusivity runs under that exclusivity in
414
+ 100% of rollbacks, verified by a concurrent execution being unable to enter the step's
415
+ forward work during the compensation.
416
+ - **SC-010**: An operator can identify the step, key, and holder of any live step-level hold,
417
+ and can tell a contention-parked execution from a failed one, without reading application
418
+ code.
419
+ - **SC-011**: A step whose key cannot be computed never executes its work.
420
+ - **SC-012**: Every existing reactor-level coordination test passes unchanged.
421
+ - **SC-013**: The demo runs end to end in the project's container setup, showing the
422
+ serialized, contended, and compensated paths.
423
+
424
+ ## Assumptions
425
+
426
+ - The audience is developers authoring reactors and steps with this library.
427
+ - Step-scoped coordination reuses the existing coordination guarantees, expiry semantics,
428
+ keep-alive behavior, and backing store; this feature changes the scope of a hold, not the
429
+ mechanism.
430
+ - Re-entrancy reuses the rules nested workflows already follow, unchanged: holds owned by the
431
+ execution, counted nesting, an execution-wide registry of held keys, refusal at hand-off
432
+ when ownership cannot be shared, and keep-ownership-across-parks with re-adoption on resume.
433
+ - The key expression receives the step's resolved arguments — the same values the step's work
434
+ receives.
435
+ - Contention behavior deliberately differs by execution path: parked-and-retried in a worker,
436
+ wait-then-fail synchronously. This is a consequence of there being no queue to step aside
437
+ into in a synchronous run; it is called out in the documentation so authors know which they
438
+ will get. A synchronous author who wants the parked behavior can run the workflow in the
439
+ background.
440
+ - Compensation re-takes only the mutual-exclusion primitives. Rate ceilings and deduplication
441
+ windows gate whether forward work happens, not whether cleanup happens.
442
+ - Declaring coordination is opt-in per step; steps that declare none behave exactly as today.
443
+ - Reactor-level declarations remain the right tool for "this whole workflow is exclusive"; step
444
+ level is for "this one operation is exclusive". Documentation must say which to reach for.
445
+ - This feature composes with steps declaring their own inputs (see
446
+ `specs/002-step-input-contracts/`), since a key expression reads the step's arguments, but
447
+ does not require it — a step wired only with reactor-side arguments can declare coordination.