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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/.specify/memory/constitution.md +26 -16
- data/.specify/templates/plan-template.md +4 -0
- data/.specify/templates/tasks-template.md +1 -1
- data/CHANGELOG.md +140 -0
- data/CLAUDE.md +1 -1
- data/README.md +125 -31
- data/lib/ruby_reactor/context.rb +2 -2
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
- data/lib/ruby_reactor/dsl/reactor.rb +36 -18
- data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
- data/lib/ruby_reactor/dsl/template_helpers.rb +2 -2
- data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
- data/lib/ruby_reactor/executor/result_handler.rb +9 -3
- data/lib/ruby_reactor/executor/retry_manager.rb +2 -1
- data/lib/ruby_reactor/executor/step_executor.rb +9 -2
- data/lib/ruby_reactor/executor.rb +3 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
- data/lib/ruby_reactor/reactor.rb +9 -12
- data/lib/ruby_reactor/rspec/matchers.rb +3 -6
- data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
- data/lib/ruby_reactor/step/compose_step.rb +56 -75
- data/lib/ruby_reactor/step/input_contract.rb +128 -0
- data/lib/ruby_reactor/step/map_step.rb +177 -218
- data/lib/ruby_reactor/step.rb +116 -21
- data/lib/ruby_reactor/step_signals.rb +6 -2
- data/lib/ruby_reactor/step_worker.rb +25 -10
- data/lib/ruby_reactor/template/result.rb +9 -2
- data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor.rb +5 -2
- data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
- data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
- data/specs/002-step-input-contracts/data-model.md +115 -0
- data/specs/002-step-input-contracts/plan.md +165 -0
- data/specs/002-step-input-contracts/quickstart.md +170 -0
- data/specs/002-step-input-contracts/research.md +233 -0
- data/specs/002-step-input-contracts/spec.md +359 -0
- data/specs/002-step-input-contracts/tasks.md +367 -0
- data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
- data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
- data/specs/004-inheritable-step-class/data-model.md +116 -0
- data/specs/004-inheritable-step-class/plan.md +174 -0
- data/specs/004-inheritable-step-class/quickstart.md +112 -0
- data/specs/004-inheritable-step-class/research.md +308 -0
- data/specs/004-inheritable-step-class/spec.md +316 -0
- data/specs/004-inheritable-step-class/tasks.md +258 -0
- data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
- data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
- data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
- data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
- data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
- data/specs/deferred-003-step-lock-declarations/research.md +196 -0
- data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
- data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
- data/specs/possible_feature.md +22 -0
- 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.
|