chrono_forge 0.9.1 → 0.11.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +390 -46
  4. data/Rakefile +4 -0
  5. data/cliff.toml +62 -0
  6. data/docs/design/per-child-commit-overhead.md +213 -0
  7. data/docs/fanout-scale-test.md +247 -0
  8. data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md +1748 -0
  9. data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md.tasks.json +17 -0
  10. data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md +930 -0
  11. data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md.tasks.json +54 -0
  12. data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md +241 -0
  13. data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md.tasks.json +12 -0
  14. data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md +1378 -0
  15. data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md.tasks.json +67 -0
  16. data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md +709 -0
  17. data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md.tasks.json +19 -0
  18. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md +205 -0
  19. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md.tasks.json +33 -0
  20. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md +1373 -0
  21. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md.tasks.json +68 -0
  22. data/docs/superpowers/specs/2026-06-03-unified-retry-policy-design.md +226 -0
  23. data/docs/superpowers/specs/2026-06-25-chrono_forge-dashboard-design.md +190 -0
  24. data/docs/superpowers/specs/2026-06-25-composite-retry-policies-design.md +228 -0
  25. data/docs/superpowers/specs/2026-06-25-reserved-kwarg-guard-design.md +169 -0
  26. data/docs/superpowers/specs/2026-06-25-spawn-merge-branches-design.md +468 -0
  27. data/docs/superpowers/specs/2026-06-26-dashboard-branch-view-design.md +142 -0
  28. data/docs/superpowers/specs/2026-06-26-deferral-continuation-race-and-catchup-design.md +265 -0
  29. data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
  30. data/lib/chrono_forge/branch_merge_job.rb +275 -0
  31. data/lib/chrono_forge/branch_probe.rb +70 -0
  32. data/lib/chrono_forge/cleanup.rb +6 -0
  33. data/lib/chrono_forge/configuration.rb +25 -0
  34. data/lib/chrono_forge/definition.rb +37 -0
  35. data/lib/chrono_forge/definition_analyzer.rb +501 -0
  36. data/lib/chrono_forge/execution_log.rb +6 -0
  37. data/lib/chrono_forge/executor/composite_retry_policy.rb +47 -0
  38. data/lib/chrono_forge/executor/context.rb +23 -0
  39. data/lib/chrono_forge/executor/lock_strategy.rb +10 -3
  40. data/lib/chrono_forge/executor/methods/branch.rb +185 -0
  41. data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
  42. data/lib/chrono_forge/executor/methods/durably_execute.rb +36 -26
  43. data/lib/chrono_forge/executor/methods/durably_repeat.rb +148 -39
  44. data/lib/chrono_forge/executor/methods/merge_branches.rb +84 -0
  45. data/lib/chrono_forge/executor/methods/wait.rb +2 -4
  46. data/lib/chrono_forge/executor/methods/wait_until.rb +25 -25
  47. data/lib/chrono_forge/executor/methods/workflow_states.rb +50 -46
  48. data/lib/chrono_forge/executor/methods.rb +2 -0
  49. data/lib/chrono_forge/executor/retry_policy.rb +111 -0
  50. data/lib/chrono_forge/executor.rb +241 -28
  51. data/lib/chrono_forge/version.rb +1 -1
  52. data/lib/chrono_forge/workflow.rb +10 -1
  53. data/lib/chrono_forge.rb +8 -0
  54. data/lib/generators/chrono_forge/migration_actions.rb +1 -0
  55. data/lib/generators/chrono_forge/templates/add_chrono_forge_parent_execution_log.rb +38 -0
  56. data/lib/tasks/release.rake +212 -0
  57. metadata +67 -4
  58. data/lib/chrono_forge/executor/retry_strategy.rb +0 -29
@@ -0,0 +1,228 @@
1
+ # Composite Retry Policies — Design
2
+
3
+ **Date:** 2026-06-25
4
+ **Status:** Approved (pending spec review)
5
+ **Scope:** Internal API, additive. Builds on the unified `RetryPolicy`
6
+ (2026-06-03). No breaking change — the common single-policy path is byte-for-byte
7
+ unchanged.
8
+
9
+ ## Problem
10
+
11
+ The unified `RetryPolicy` answers "should we retry?" with a single
12
+ `max_attempts`/`backoff`/`retry_on` tuple per retry site. A single tuple cannot
13
+ express *different behavior per error type* — yet that is exactly what real
14
+ workflows (fintech especially) need:
15
+
16
+ - `NetworkError` → retry aggressively, short backoff
17
+ - `RateLimitError` → retry more, longer backoff
18
+ - `PaymentDeclinedError` → fail immediately, do not retry
19
+
20
+ Today you must pick one policy for the whole step. A `retry_on:` allowlist filters
21
+ *which* errors retry, but every retried error shares one `max_attempts` and one
22
+ backoff curve.
23
+
24
+ ## Goal
25
+
26
+ Let a retry site be configured with an **ordered list** of `RetryPolicy` objects.
27
+ On failure, the **first** sub-policy whose `retry_on` matches the raised error
28
+ applies its own `max_attempts`/`backoff`. Each error type gets an **independent
29
+ attempt budget**.
30
+
31
+ The single-policy path is untouched: it keeps using the site's own `attempts`
32
+ counter, with no extra state.
33
+
34
+ ## Decisions (locked during brainstorming)
35
+
36
+ | Decision | Choice |
37
+ |---|---|
38
+ | Counting semantics | **Per-error budgets.** A sub-policy's `max_attempts` counts only failures routed to it. |
39
+ | Where the count lives | A `retry_counts` map keyed by **matched-policy index**. Steps: in the (execution/repetition) log `metadata`. Workflow-level: in the job args, beside `attempt:`. No new column, no `error_logs` query. |
40
+ | Subclass safety | Routing happens **once, on the live error** via `matches?`/`is_a?` — subclass-correct. The count is keyed by the matched policy, never by error class, so no constantizing or class-string matching at decision time. |
41
+ | Single-policy path | Unchanged — uses the site's `attempts` counter directly, no `retry_counts`. |
42
+ | Construction | Named factory `RetryPolicy.compose(*policies)`; passing an `Array` to `retry_policy:` coerces through the same factory. |
43
+ | Scope | **All four sites**, including the workflow-level class default. |
44
+ | Routing | First match wins. `retry_on: nil` (catch-all) typically last; no match → no retry (fail fast). |
45
+ | Purity | `RetryPolicy` and `CompositeRetryPolicy` stay pure value objects. The per-error count is incremented/read by the executor via a block; the policies never touch storage. |
46
+
47
+ ## Why increment-then-check preserves existing semantics
48
+
49
+ `attempts` is 1-based and includes the failure that just happened (so on the
50
+ first failure `attempts == 1`, and `retryable?` checks `attempts < max_attempts`).
51
+ The composite mirrors this: on each failure it **increments** the matched
52
+ policy's counter **then** checks, so the value handed to `retryable?` is "failures
53
+ routed to this policy so far, including the current one" — the same shape the
54
+ single-policy path already uses. The two notions agree, so a per-error count
55
+ substitutes cleanly wherever a plain policy uses `attempts`.
56
+
57
+ ## Components
58
+
59
+ ### 1. `RetryPolicy` (existing — additions, pure)
60
+
61
+ - `matches?(error)` — public routing predicate; wraps the existing private
62
+ `retryable_error?`. `retry_on: nil` matches any `StandardError` (catch-all);
63
+ `retry_on: []` matches nothing. Subclass-correct (`error.is_a?`).
64
+ - `retry_backoff(error, attempts:) { |policy_index| count }` — returns the backoff
65
+ `Duration` to retry, or `nil` to stop. The plain policy **ignores the block**
66
+ and uses `attempts`:
67
+
68
+ ```ruby
69
+ def retry_backoff(error, attempts:)
70
+ retryable?(error, attempts) ? backoff_for(attempts) : nil
71
+ end
72
+ ```
73
+
74
+ - `self.compose(*policies)` — factory returning a `CompositeRetryPolicy`.
75
+
76
+ `retryable?`, `backoff_for`, `max_attempts` are unchanged (tests and the
77
+ single-policy path depend on them).
78
+
79
+ ### 2. `CompositeRetryPolicy` (new — `executor/composite_retry_policy.rb`, pure)
80
+
81
+ ```ruby
82
+ class CompositeRetryPolicy
83
+ attr_reader :policies
84
+
85
+ def initialize(policies)
86
+ @policies = Array(policies)
87
+ raise ArgumentError, "composite retry policy needs at least one policy" if @policies.empty?
88
+ end
89
+
90
+ # First sub-policy whose retry_on matches the error, or nil.
91
+ def policy_for(error)
92
+ @policies.find { |p| p.matches?(error) }
93
+ end
94
+
95
+ # Routes on the *live* error, yields the matched policy's index so the caller
96
+ # can increment and return that policy's running count, then delegates the
97
+ # decision to the matched sub-policy.
98
+ def retry_backoff(error, attempts:)
99
+ idx = @policies.index { |p| p.matches?(error) }
100
+ return nil if idx.nil?
101
+
102
+ sub = @policies[idx]
103
+ count = block_given? ? yield(idx) : attempts
104
+ sub.retryable?(error, count) ? sub.backoff_for(count) : nil
105
+ end
106
+
107
+ # Coarsest bound, for the workflow-level safety-net guard in `perform`.
108
+ # nil if any sub-policy is unbounded.
109
+ def max_attempts
110
+ caps = @policies.map(&:max_attempts)
111
+ caps.include?(nil) ? nil : caps.max
112
+ end
113
+ end
114
+ ```
115
+
116
+ Routing is by `matches?`, which is `is_a?`-based, so a subclass of a `retry_on`
117
+ class routes to the right policy. The returned **index** — not the error class —
118
+ is the counter key, so subclasses share the budget of the policy they routed to,
119
+ exactly as intended.
120
+
121
+ ### 3. Executor wiring
122
+
123
+ - **`coerce_policy(value)`** — `Array` → `RetryPolicy.compose(*value)`; a
124
+ `RetryPolicy` or `CompositeRetryPolicy` passes through; `nil` → `nil`. Applied
125
+ in `step_retry_policy` and `wait_retry_policy`, and to the class DSL.
126
+
127
+ - **Class DSL** `retry_policy(*policies, **opts)` — positional policies →
128
+ `RetryPolicy.compose(*policies)` stored as `default_retry_policy`; kwargs only →
129
+ `RetryPolicy.new(**opts)` (unchanged). Mixing both raises `ArgumentError`.
130
+
131
+ - **Per-error counter, step sites** — one helper, incrementing the matched
132
+ policy's slot in the log metadata and returning the new count:
133
+
134
+ ```ruby
135
+ RETRY_COUNTS_KEY = "retry_counts"
136
+
137
+ def bump_retry_count!(log, policy_index)
138
+ meta = log.metadata || {}
139
+ counts = meta[RETRY_COUNTS_KEY] || {}
140
+ key = policy_index.to_s
141
+ counts[key] = counts[key].to_i + 1
142
+ meta[RETRY_COUNTS_KEY] = counts
143
+ log.update!(metadata: meta) # explicit reassign so the JSON column is marked dirty
144
+ counts[key]
145
+ end
146
+ ```
147
+
148
+ - **Per-error counter, workflow level** — `perform` gains `retry_counts: {}` in
149
+ its signature; the failure path increments the in-memory map and threads it
150
+ through the reschedule, beside `attempt:`. No DB write (mirrors `attempt:`).
151
+
152
+ - **The four retry sites** change from the `retryable? … backoff_for` pair to a
153
+ single `retry_backoff` call carrying the site's counter block. A single policy
154
+ ignores the block, so its path is unchanged and writes no `retry_counts`:
155
+
156
+ ```ruby
157
+ backoff = policy.retry_backoff(e, attempts: COUNT) { |idx| <bump counter for idx> }
158
+ if backoff
159
+ self.class.set(wait: backoff).perform_later(@workflow.key, *site_args)
160
+ halt_execution!
161
+ else
162
+ # site-specific terminal action
163
+ end
164
+ ```
165
+
166
+ Per-site `COUNT` / counter store / terminal action:
167
+
168
+ | Site | `COUNT` (single-policy) | Composite counter store | Terminal action |
169
+ |---|---|---|---|
170
+ | `perform` (workflow) | `attempt + 1` | job-args `retry_counts` (rescheduled with `attempt: attempts_made, retry_counts:`) | `fail_workflow!(error_log)` |
171
+ | `durably_execute` | `execution_log.attempts` | `execution_log.metadata` | mark failed, raise `ExecutionFailedError` |
172
+ | `durably_repeat` | `repetition_log.attempts` | `repetition_log.metadata` | `on_error` (`:continue` / `:fail_workflow`) |
173
+ | `wait_until` | `execution_log.attempts` | `execution_log.metadata` | mark failed, raise `ExecutionFailedError` |
174
+
175
+ `durably_repeat` keys its counter on the per-repetition log, so each repetition
176
+ gets its own independent per-error budgets.
177
+
178
+ ## Notable properties
179
+
180
+ - **Per-error backoff escalation.** `backoff_for(count)` uses the per-error count
181
+ as the exponent, so each error type's backoff escalates on its own schedule.
182
+ - **No class-string matching at decision time.** Subclass resolution is a single
183
+ `is_a?` on the live error during routing; the counter is keyed by policy index.
184
+ - **`wait_until` still does not inherit the class default.** A per-call array is
185
+ coerced normally; the class-level composite default does not leak in.
186
+ - **Workflow-level safety net.** `perform`'s early guard
187
+ (`policy.max_attempts && attempt >= policy.max_attempts`) keeps working because
188
+ `CompositeRetryPolicy#max_attempts` returns the coarsest bound — a safe
189
+ over-estimate that never kills a workflow prematurely.
190
+ - **Ordering matters.** Specific policies first, catch-all (`retry_on: nil`) last;
191
+ without a catch-all, an unmatched error fails fast. (Documented footgun.)
192
+ - **Mid-flight reorder caveat.** Counts are keyed by policy index, so reordering a
193
+ composite's policies while a long-running workflow is in flight can misattribute
194
+ in-progress counts. Reordering retry config mid-workflow is ambiguous under any
195
+ scheme; documented as a known edge.
196
+
197
+ ## Testing
198
+
199
+ **Unit — `CompositeRetryPolicy`**
200
+ - routing: first match wins; specific-before-catch-all
201
+ - catch-all (`retry_on: nil`) matches anything; `retry_on: []` matches nothing
202
+ - subclass of a `retry_on` class routes to that policy (and yields its index)
203
+ - no match → `retry_backoff` returns `nil`
204
+ - `retry_backoff` yields the matched policy's index and uses the yielded count for
205
+ both the cap check and the backoff exponent
206
+ - `max_attempts` = coarsest bound; `nil` if any sub-policy unbounded
207
+ - empty policy list raises `ArgumentError`
208
+
209
+ **Unit — `RetryPolicy` additions**
210
+ - `matches?` semantics for `nil` / `[]` / class list incl. subclasses
211
+ - `retry_backoff` (plain) ignores the block, returns `nil` past the cap
212
+ - `RetryPolicy.compose` builds a `CompositeRetryPolicy`
213
+
214
+ **Unit — `bump_retry_count!`**
215
+ - increments the right index slot; independent slots accumulate independently
216
+ - reassigns `metadata` so the JSON column persists across reload
217
+ - `nil`/absent `metadata` initializes cleanly
218
+
219
+ **Integration**
220
+ - a step raising different error types accumulates independent per-error budgets
221
+ and per-error backoff; fail-fast policy (`max_attempts: 1`) stops immediately;
222
+ subclass of a `retry_on` class draws from the parent policy's budget
223
+ - regression: a single `RetryPolicy` (per-call, class default, built-in) behaves
224
+ identically to today and writes no `retry_counts`
225
+ - array passed to `retry_policy:` is coerced to a composite
226
+ - workflow-level composite default routes correctly, threads `retry_counts`
227
+ through reschedules, and the `perform` safety-net guard honors the coarse
228
+ `max_attempts`
@@ -0,0 +1,169 @@
1
+ # Reserved-keyword guard + keywords-only enqueue contract
2
+
3
+ Date: 2026-06-25
4
+ Status: Approved (design)
5
+
6
+ ## Problem
7
+
8
+ `ChronoForge::Executor#perform` reserves several keyword parameters for internal
9
+ plumbing:
10
+
11
+ ```ruby
12
+ def perform(key, attempt: 0, retry_counts: {}, retry_workflow: false, options: {}, **kwargs)
13
+ ```
14
+
15
+ Anything not named here lands in `**kwargs`, is persisted on the `Workflow` row,
16
+ and is replayed to the user's job body via `super(**workflow.kwargs.symbolize_keys)`
17
+ (`executor.rb:100`).
18
+
19
+ Two problems follow from the current public enqueue surface
20
+ (`perform_now`/`perform_later`), which only validate that `key` is a String:
21
+
22
+ 1. **Silent collision.** A user calling `MyJob.perform_later("k", attempt: 5)`
23
+ silently hijacks the internal retry counter instead of passing their own
24
+ argument. Same risk for `retry_counts` and `retry_workflow`.
25
+ 2. **No positional contract.** Extra positional arguments produce Ruby's generic
26
+ `wrong number of arguments` error rather than a contract-specific message —
27
+ even though the executor only ever replays kwargs (keyword-only) to the job
28
+ body, so positionals beyond `key` can never work.
29
+
30
+ ## Decisions (settled with maintainer)
31
+
32
+ - **Public keyword surface:** `key` (required, first positional), `options`
33
+ (free-form metadata bag), and user `**kwargs`. Nothing else.
34
+ - **`options` is unstructured.** The framework defines **zero** recognized option
35
+ keys. `options` is written to `workflow.options` (`executor.rb:159`) and only
36
+ ever read back by callers (`workflow.options`); no key in it drives behavior.
37
+ - **Reserved keys (rejected on the public path):** `attempt`, `retry_counts`,
38
+ `retry_workflow`. These are internal threading params; users have no legitimate
39
+ reason to pass them.
40
+ - **`retry_workflow` is internal**, reached only through the `retry_now` /
41
+ `retry_later` helpers — not by passing the flag directly.
42
+ - **Keywords-only:** exactly one positional (`key`); everything else must be a
43
+ keyword, enforced with a clear, contract-specific error.
44
+ - **No job-signature validation.** We do *not* introspect the job's `perform`
45
+ parameters to validate unknown/missing kwargs. Out of scope; mismatches still
46
+ surface at execution time as today.
47
+
48
+ ## Mechanism: how internal calls bypass the guard
49
+
50
+ The split between "framework may pass reserved keys" and "users may not" rests on
51
+ an ActiveJob implementation detail, confirmed on ActiveJob 7.1.3.4:
52
+
53
+ > `ActiveJob::ConfiguredJob` (returned by `.set(...)`) defines its own
54
+ > `perform_now` / `perform_later` that build a fresh job instance and call the
55
+ > **instance-level** enqueue path. They do **not** dispatch through the
56
+ > **class-level** `perform_*` override.
57
+
58
+ Therefore any enqueue routed through `.set(...)` bypasses the guard:
59
+
60
+ - All framework continuations already use `.set(wait: …).perform_later(key, …)`
61
+ (`executor.rb:138`, `wait.rb`, `wait_until.rb`, `durably_repeat.rb`,
62
+ `durably_execute.rb`) — their `attempt:`/`retry_counts:`/`wait_condition:`
63
+ ride through untouched.
64
+ - `retry_now` / `retry_later` are rewritten to enqueue via `set.perform_*`,
65
+ legitimately injecting `retry_workflow: true` past the guard.
66
+
67
+ This dependency is non-obvious and now load-bearing, so it is documented inline
68
+ at the guard.
69
+
70
+ ## Design
71
+
72
+ All changes land in `lib/chrono_forge/executor.rb`, in the `class << base` block,
73
+ plus one module-level constant. ~30 lines. No schema or behavior changes
74
+ elsewhere.
75
+
76
+ ### 1. Reserved-key constant (module level, near `STEP_NAME_DELIMITER`)
77
+
78
+ ```ruby
79
+ # Keyword args ChronoForge threads through job args internally. Users must not
80
+ # pass these to perform_now/perform_later; the framework injects them via
81
+ # `.set(...)` continuations, whose ConfiguredJob proxy bypasses the class-level
82
+ # guard below.
83
+ RESERVED_KWARGS = %i[attempt retry_counts retry_workflow].freeze
84
+ ```
85
+
86
+ ### 2. Public guards — `perform_now` / `perform_later`
87
+
88
+ ```ruby
89
+ def perform_now(key, *extra, **kwargs)
90
+ __validate_enqueue!(key, extra, kwargs)
91
+ super(key, **kwargs)
92
+ end
93
+
94
+ def perform_later(key, *extra, **kwargs)
95
+ __validate_enqueue!(key, extra, kwargs)
96
+ super(key, **kwargs)
97
+ end
98
+
99
+ private
100
+
101
+ def __validate_enqueue!(key, extra, kwargs)
102
+ unless key.is_a?(String)
103
+ raise ArgumentError, "Workflow key must be a string as the first argument"
104
+ end
105
+ unless extra.empty?
106
+ raise ArgumentError, "ChronoForge workflows accept only `key` positionally; " \
107
+ "pass everything else as keywords (got #{extra.size} extra positional arg(s))"
108
+ end
109
+ reserved = kwargs.keys & RESERVED_KWARGS
110
+ if reserved.any?
111
+ raise ArgumentError,
112
+ "#{reserved.join(", ")} #{reserved.one? ? "is a reserved" : "are reserved"} " \
113
+ "ChronoForge keyword(s) and cannot be passed to perform_now/perform_later"
114
+ end
115
+ end
116
+ ```
117
+
118
+ `*extra` exists solely to catch stray positionals and produce the clear error;
119
+ after validation it is always empty and discarded (only `super(key, **kwargs)`
120
+ is forwarded).
121
+
122
+ ### 3. Retry helpers — route past the guard
123
+
124
+ ```ruby
125
+ def retry_now(key, **kwargs)
126
+ __validate_enqueue!(key, [], kwargs)
127
+ set.perform_now(key, retry_workflow: true, **kwargs)
128
+ end
129
+
130
+ def retry_later(key, **kwargs)
131
+ __validate_enqueue!(key, [], kwargs)
132
+ set.perform_later(key, retry_workflow: true, **kwargs)
133
+ end
134
+ ```
135
+
136
+ They still validate the *user's* kwargs (rejecting any reserved key the user
137
+ supplied), then inject `retry_workflow: true` through the `ConfiguredJob` bypass.
138
+
139
+ ### 4. Framework continuations — unchanged
140
+
141
+ `executor.rb:138`, `wait.rb`, `wait_until.rb`, `durably_repeat.rb`,
142
+ `durably_execute.rb` already enqueue via `.set(...)`. No change required.
143
+
144
+ ## Scope / caveats
145
+
146
+ - **Executor-only.** The guard lives in the `Executor`-prepended singleton.
147
+ `ChronoForge::CleanupJob` is a plain `ActiveJob::Base` and is unaffected
148
+ (its `perform_now(older_than_days: …)` / arg-less `perform_later` keep working).
149
+ - **Backward compatible.** A full scan of `lib/` and `test/` found no call site
150
+ passing a second positional and no user call passing a reserved key, so the
151
+ existing suite passes unchanged.
152
+ - **`wait_condition`** (internal kwarg in `wait_until`) is intentionally *not*
153
+ added to `RESERVED_KWARGS`: it only ever travels via `.set(...)` and so never
154
+ reaches the guard. Adding it later is a harmless one-line hygiene change if
155
+ desired.
156
+
157
+ ## Testing (TDD)
158
+
159
+ New tests (Executor-prepended job):
160
+
161
+ 1. `perform_later` / `perform_now` raise `ArgumentError` when passed `attempt:`,
162
+ `retry_counts:`, or `retry_workflow:` — and the message names the key(s).
163
+ 2. `perform_later` / `perform_now` raise `ArgumentError` with the contract
164
+ message when passed a second positional argument.
165
+ 3. `perform_later("k", kwarg: "x", options: {a: 1})` still enqueues; `options`
166
+ and user kwargs reach the workflow (`workflow.options`, `workflow.kwargs`).
167
+ 4. `retry_now` / `retry_later` still unlock-and-continue a stalled workflow
168
+ (existing behavior preserved), and reject reserved keys passed by the caller.
169
+ 5. Non-String `key` still raises (regression guard for existing behavior).