cmdx 2.1.1 → 2.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03bacf5f39e3f6588988b533606f53a90cdba94beef7bc2c9303e1e00c53a989
4
- data.tar.gz: 02f9e8a212ace89b6d9e7b134a9b92b469ce049b553876506e5d6786bede830e
3
+ metadata.gz: 3006157f5e5d98af8e442130f87f0427f15868e84e44db171c1f620d0f606ef5
4
+ data.tar.gz: 92e7a594a9ef6b4367bfbeb3fc71b807d7d495d9e977007af8a28b498c0730d9
5
5
  SHA512:
6
- metadata.gz: 4d76b548878d4b78ed131a18804c4810d928fa99dfa66cf31723a475e89034ccac16451ed62e1be7880be491911eaef31b215c9540d2a7e4b864b716ac5a8cf7
7
- data.tar.gz: 107083dfef5796ed6f628b662d1920bb0e7028e0ea9a56ecaf63d979f261c248d6ab113c3881079522352abe97fdde451d8e741403a77f1b38ea2b082d003179
6
+ metadata.gz: 33ab98bd8eaa2d36c5f01cc790bb09ed644a1f4a01cd561545333c83059cfb1656f9349e684920f2377a99e8fe0fd5409f9e6199e8966358718f37c560cef911
7
+ data.tar.gz: 5276f8e49a7697ce9fca44f64d7bc393429b8e75636f3e8b5865a9388f14f6e96aa9c039f7c3e4efa583e9d7d5c0097a845ca4878dfd73e6a74ea76e1e5b72cf
data/CHANGELOG.md CHANGED
@@ -4,12 +4,31 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [2.x.x] - UNRELEASED
7
+ ## UNRELEASED
8
+
9
+ ## [2.3.0] - UNRELEASED
10
+
11
+ ### Added
12
+ - `Task.lock_with` — distributed lease via `CMDx::Stores` so only one worker runs the same logical key at a time; optional `wait:` polling, `on_busy:` `:fail` / `:skip`, and `if` / `unless` gates; wraps `idempotent_by` and the `retry_on` loop in `Runtime`; pass `false` on a subclass to disable
13
+ - `CMDx::Stores` — `get` / `set` / `incr` / `del` with optional TTL, delegating to `CMDx.configuration.store`; ships with `Stores::Memory` (default) and `Stores::Redis` (Redis-compatible client + namespace prefix)
14
+ - `Task.idempotent_by` — declare a stable key from context (or a callable) so duplicate invocations short-circuit via `CMDx::Stores` before `work` runs; releases the claim on failure so `retry_on` can retry safely; pass `false` on a subclass to disable
15
+ - `Task.circuit_breaker` — shared failure counter and open flag via `CMDx::Stores` so repeated `StandardError` outcomes open the circuit until `cool_off` elapses; wraps `idempotent_by` and the `retry_on` loop in `Runtime`; pass `false` on a subclass to disable
16
+ - `Task.retry_on` — pass `false` on a subclass to disable inherited retries (`Retry.disabled`, `Retry#enabled?`)
17
+ - Telemetry events for `lock_with`, `idempotent_by`, and `circuit_breaker` — `:lock_acquired`, `:lock_busy`, `:idempotency_claimed`, `:idempotency_completed`, `:idempotency_duplicate`, `:circuit_open`, `:circuit_tripped`, and `:circuit_reset` (gated by `Telemetry#subscribed?` like lifecycle events); `Telemetry.emit` centralizes dispatch from `Runtime`, `Pipeline`, and store-backed helpers
18
+
19
+ ## [2.2.0] - 2026-10-17
20
+
21
+ ### Added
22
+ - Post-work lifecycle callbacks (`after_execution`, every `on_*` hook) receive the finalized `Signal` as their second argument — `reason`, `cause`, `metadata`, `origin`, and `backtrace` are available before `Result` construction. Pre-work hooks and `around_execution` keep the single-`task` arity
23
+ - `Input#allow_nil` — permit `nil` after defaults without coercion, transform, or validation
24
+ - `Fault#detailed_message` — appends validation errors and propagation `origin` to the stdlib detailed message for IRB and debug formatters without changing `message`
25
+ - `Fault#inspect` — compact task/reason/errors summary for console debugging
8
26
 
9
27
  ## [2.1.1] - 2026-07-26
10
28
 
11
29
  ### Added
12
30
  - `Result#to_h` includes `:errors` (`Errors#to_h`) for structured validation messages in logs, telemetry, and JSON serialization
31
+ - Add `:decimal` coercion alias for `BigDecimal`
13
32
 
14
33
  ### Fixed
15
34
  - `Input` resolution continues coerce → transform → validate when the key is present (including explicit `nil`) instead of stopping after defaults; omitted keys still stop when the value is `nil` after defaults
data/TODO.md ADDED
@@ -0,0 +1,353 @@
1
+ # CMDx roadmap (ideas)
2
+
3
+ Items here are **exploratory roadmap ideas**, not shipped features or release commitments. Names, APIs, and layering may change as designs are validated against real apps and benchmarks.
4
+
5
+ Use this file to track **what we might build next** and **why**. When work starts on an item, link a design note or issue and move status toward *in progress* / *done* in the changelog instead of treating this doc as the source of truth for behavior.
6
+
7
+ ---
8
+
9
+ ## Infrastructure primitives
10
+
11
+ ### Serializers (`config.serializers` + registry)
12
+
13
+ **Status:** Idea (foundational — intended before or alongside cache and checkpoint)
14
+ **Theme:** One generic encode/decode layer for anything that persists Ruby-ish data through `CMDx::Stores` or job payloads.
15
+
16
+ #### Problem
17
+
18
+ `CMDx::Stores` only speaks **strings** (see [Stores](docs/stores.md)). Features that need to round-trip structured state—workflow checkpoints, cached task outputs, large context snapshots—must agree on:
19
+
20
+ - **Format** (JSON, MessagePack, compressed JSON, app-specific encryption),
21
+ - **Versioning** (blob schema when task classes or context keys change),
22
+ - **Safety** (what types may appear in a payload; no accidental `Marshal` of arbitrary objects),
23
+ - **Failure modes** (corrupt blob, unknown serializer id, decode errors).
24
+
25
+ Today, `Context#to_json` and `Result#to_h` help with logging and introspection, but they are not a **contract** for store-backed persistence. Cache and checkpoint would otherwise each invent their own envelope and error types.
26
+
27
+ #### Intended function
28
+
29
+ A small **serializer framework** parallel to existing registries (`coercions`, `validators`, `executors`, `mergers`):
30
+
31
+ 1. **Global config** — `CMDx.configure { |c| c.serializers = … }` with a default registry instance (and `CMDx.reset_configuration!` restores it in tests, same hierarchy rules as other registries).
32
+ 2. **Registry** — `CMDx::Serializers` (name TBD) maps **symbols** to serializer objects that implement a narrow contract, e.g. `#dump(object) → String` and `#load(string) → object` (exact names aligned with Ruby conventions).
33
+ 3. **Built-ins** — at minimum a **JSON** serializer that produces UTF-8 strings safe for Redis/memory adapters; optional built-ins (e.g. compressed JSON, MessagePack) only if they stay stdlib/light-deps friendly or live behind optional requires.
34
+ 4. **Registration** — `CMDx.serializers.register :my_app, MySerializer` or `config.serializers.register(...)` so apps can plug encryption, Avro, or “store id in SQL, blob in S3” wrappers without forking core.
35
+ 5. **Envelope** — higher-level helpers (not necessarily exposed to every user) wrap domain objects in a **versioned document**, e.g. serializer id, format version, task/workflow identity, payload — so cache and checkpoint share one on-the-wire shape and telemetry can record `serializer:` and `bytes:`.
36
+ 6. **Resolution order** — feature-level option → task/workflow `settings` → global default (same layering story as loggers and middlewares).
37
+ 7. **Explicit boundaries** — serializers used for **persistence** should default to **JSON-safe** data (Hash-like structures with string keys, scalars, arrays); opt-in hooks for types that implement a CMDx protocol (e.g. `#as_cmdx_payload` / `.from_cmdx_payload`) instead of implicit `as_json` on every value.
38
+
39
+ #### Proposed API (sketch)
40
+
41
+ ```ruby
42
+ # config/initializers/cmdx.rb
43
+ CMDx.configure do |config|
44
+ config.serializers = CMDx::Serializers.new
45
+ config.serializers.register :json, CMDx::Serializers::JSON.new
46
+ config.serializers.register :encrypted_json, MyApp::EncryptedJsonSerializer.new
47
+ config.default_serializer = :json
48
+ end
49
+
50
+ # Lookup
51
+ blob = CMDx.serializers[:json].dump({ "v" => 1, "outputs" => { "rate" => "1.02" } })
52
+ data = CMDx.serializers[:json].load(blob)
53
+
54
+ # Feature override (cache / checkpoint / future store features)
55
+ class FetchExchangeRate < ApplicationTask
56
+ cache_result key: :pair, ttl: 300, serializer: :json
57
+ end
58
+
59
+ class FulfillmentWorkflow < CMDx::Task
60
+ include CMDx::Workflow
61
+ settings checkpoint_serializer: :encrypted_json
62
+ end
63
+ ```
64
+
65
+ #### Design notes
66
+
67
+ - **Not idempotency values:** Idempotency store entries (`processing` / `completed`) stay tiny opaque strings; serializers target **payload blobs**, not lease tokens.
68
+ - **Size limits:** Registry or envelope may expose `max_bytes` / compression hints; adapters can refuse writes before hitting Redis limits.
69
+ - **Corruption:** `#load` raises a dedicated error (subclass of `CMDx::Error` or similar) so cache/checkpoint can treat decode failure as a **miss** or **resume from scratch**, with telemetry `:serializer_decode_failed`.
70
+ - **Testing:** In-memory store + round-trip helpers in spec support (`dump_load_roundtrip`) keep features from re-testing JSON edge cases.
71
+ - **Job adapters:** The same registry can validate enqueue payloads (`serializers[:json].dump(context_hash)` must succeed) so background-job and store features share one definition of “serializable context.”
72
+
73
+ #### Open questions
74
+
75
+ - Single `CMDx::Serializers::JSON` vs reusing `Context#to_json` / custom key normalization (symbols → strings)?
76
+ - Built-in **allowlist** of context keys per task (`persist: %i[rate user_id]`) enforced at dump time?
77
+ - Whether envelope lives in **core** or in a `CMDx::Persistence::Envelope` module shared by cache/checkpoint only?
78
+ - Compression as a serializer decorator vs separate `:gzip_json` registration?
79
+
80
+ #### Related
81
+
82
+ - [Configuration](docs/configuration.md) — pattern for global registries
83
+ - [Stores](docs/stores.md) — string values, TTL
84
+ - `CMDx::Context#to_json`, `CMDx::Result#to_h` — logging/introspection, not persistence contract
85
+ - `cache_result` and **Checkpoint / resume** (below) — primary consumers
86
+
87
+ ---
88
+
89
+ ### `CMDx::Cache` / `cache_result`
90
+
91
+ **Status:** Idea
92
+ **Theme:** Cross-process memoization of successful task runs (read-through cache), distinct from idempotency.
93
+
94
+ #### Problem
95
+
96
+ Many tasks are **pure or expensive-but-deterministic** for a given input set: geocoding, FX rates, permission snapshots, third-party “get user” calls. Today you either:
97
+
98
+ - Recompute on every `execute`, or
99
+ - Hand-roll caching in `#work` with Redis/Memcached and inconsistent key/TTL/error semantics.
100
+
101
+ `idempotent_by` solves **duplicate side effects** (at-most-once *execution*), not **reuse of a prior successful result** when you are happy to skip `work` entirely and return stored outputs.
102
+
103
+ #### Intended function
104
+
105
+ A declarative helper (working name `cache_result`, backed by something like `CMDx::Cache`) that:
106
+
107
+ 1. Runs **after** input resolution (same general placement as idempotency/locks in `Runtime`) so keys are built from validated, coerced inputs and context.
108
+ 2. Computes a **stable cache key** from the task class and a user-defined segment (symbol on context, proc, etc.—same ergonomics as `idempotent_by` / `lock_with`).
109
+ 3. On **cache hit** with a valid, non-expired entry: short-circuit before `#work`, hydrate `context` / declared **outputs** from the stored payload, and finish with a successful result (telemetry should distinguish *cached* vs *executed*).
110
+ 4. On **miss**: run the normal lifecycle (`work`, output verification, etc.). On **success only**, **dump** a snapshot of the return surface (outputs and any agreed context keys) through the configured **serializer** into `CMDx::Stores` with a TTL.
111
+ 5. On **failure** (`fail!`, `skip!`, raised errors): do **not** write or refresh the cache entry (failed runs must not poison the cache).
112
+
113
+ #### Proposed API (sketch)
114
+
115
+ ```ruby
116
+ class FetchExchangeRate < ApplicationTask
117
+ cache_result key: ->(t) { "#{t.base_currency}:#{t.quote_currency}:#{t.as_of}" },
118
+ ttl: 300,
119
+ namespace: "cmdx:cache",
120
+ serializer: :json
121
+
122
+ required :base_currency, :quote_currency
123
+ required :as_of, coerce: :date
124
+
125
+ output :rate
126
+
127
+ def work
128
+ context.rate = ExternalRates.fetch(base_currency, quote_currency, as_of)
129
+ end
130
+ end
131
+ ```
132
+
133
+ #### Design notes
134
+
135
+ - **Shared store layer:** Reuse `CMDx::Stores` (Redis/Memory adapters, TTL, namespacing) so cache entries behave like idempotency and circuit-breaker keys in multi-worker deployments.
136
+ - **Similar machinery to checkpoint (below):** Key composition, TTL, conditional writes, and telemetry hooks should follow the same patterns as `Idempotency` and `Lock`—one implementation style for “store-backed runtime primitives.”
137
+ - **Serializers registry:** Use `config.serializers` (see above) for dump/load; cache owns *what* to persist (outputs, optional context keys), not *how* to encode bytes.
138
+ - Must respect `output` declarations and avoid caching secrets unless the app opts in (`serializer:` / allowlists).
139
+ - **Interaction with `idempotent_by`:** A duplicate *invocation* might still be an idempotency skip while cache is a separate concern; ordering in `Runtime` and docs need to spell out combined behavior (e.g. idempotency claim first, then cache read inside the claim).
140
+
141
+ #### Open questions
142
+
143
+ - Cache **invalidation** API (manual `del`, version in key, `if:` / `unless:` gates)?
144
+ - Should cache hits **run callbacks** (`on_executed`, telemetry subscribers) or a lighter “cache_hit” event only?
145
+ - Stale-while-revalidate or single-flight deduplication under load?
146
+
147
+ #### Related
148
+
149
+ - [Stores](docs/stores.md) — planned home for cache and checkpoint bytes
150
+ - [Idempotency](docs/idempotency.md) — duplicate execution, not result reuse
151
+ - `CMDx::Idempotency` — reference for store key layout and `Runtime` wrapping
152
+ - **Serializers** (above) — shared encode/decode for cache entries
153
+
154
+ ---
155
+
156
+ ## Tasks
157
+
158
+ ### `execute_async` (non-blocking execution)
159
+
160
+ **Status:** Idea
161
+ **Theme:** Run a task without blocking the caller; observe completion via a future-like handle.
162
+
163
+ #### Problem
164
+
165
+ `Task.execute` / `execute!` are **synchronous**: the caller blocks until `Runtime` finishes, including retries, locks, and parallel workflow children. That is correct for HTTP requests and scripts, but awkward when you want to:
166
+
167
+ - Kick off work and continue in the same process (UI thread, event loop, `Async` integration),
168
+ - Compose multiple tasks concurrently without manually managing threads/fibers,
169
+ - Attach timeouts or cancellation at the call site without pushing that into every task.
170
+
171
+ Apps today wrap `execute` in `Thread.new`, custom executors, or job queues—losing consistent telemetry, chain semantics, and error surfaces.
172
+
173
+ #### Intended function
174
+
175
+ An async entry point (name TBD, e.g. `execute_async`) that:
176
+
177
+ 1. Schedules the same **full** `Runtime` lifecycle on a configurable executor (thread pool, fiber scheduler, or injected backend).
178
+ 2. Returns immediately with a **future-like object** that can:
179
+ - Block with `value` / `wait` (raising under `execute!`-equivalent strictness),
180
+ - Poll `ready?` / `failed?`,
181
+ - Expose the frozen `CMDx::Result` when complete,
182
+ - Optionally propagate **correlation** into the child execution’s chain/telemetry.
183
+ 3. Preserves existing semantics: middlewares, callbacks, idempotency, locks, circuit breaker, and output verification run unchanged inside the async run.
184
+ 4. Documents interaction with **fiber-local** `Chain` (child run should not corrupt the parent’s chain; likely a fresh chain root unless explicitly linked).
185
+
186
+ #### Proposed API (sketch)
187
+
188
+ ```ruby
189
+ future = ProcessUpload.execute_async(file_id: upload.id)
190
+ # ... do other work ...
191
+ result = future.wait # blocks
192
+ result = future.wait(timeout: 5) # TBD
193
+
194
+ ProcessUpload.execute_async(file_id: upload.id).then { |r| notify(r) } # TBD callback style
195
+ ```
196
+
197
+ #### Design notes
198
+
199
+ - Not a replacement for **distributed** background jobs (see next section); optimized for **in-process** concurrency and testability.
200
+ - Strict vs non-strict should mirror `execute` / `execute!` on the future.
201
+ - Error objects raised on `wait` should match today’s `Fault` / exception story.
202
+
203
+ #### Open questions
204
+
205
+ - Default executor vs explicit `executor:` on each call vs global config?
206
+ - Cancellation API (kill thread vs cooperative cancel token into `work`)?
207
+ - Integration with workflow `:parallel` groups—shared pool or separate?
208
+
209
+ #### Related
210
+
211
+ - `CMDx::Runtime` — lifecycle to schedule unchanged
212
+ - [Workflows – parallel groups](docs/workflows.md) — existing in-pipeline concurrency
213
+ - Blog: [Background Jobs + CMDx](docs/blog/posts/real-world-cmdx-background-jobs.md) — when async leaves the process
214
+
215
+ ---
216
+
217
+ ### Background-job adapter (`perform_async` / `perform_in` / `perform_at`)
218
+
219
+ **Status:** Idea
220
+ **Theme:** First-class enqueue helpers for Sidekiq, ActiveJob, GoodJob, etc., with safe serialization at the boundary.
221
+
222
+ #### Problem
223
+
224
+ Production apps almost always run CMDx at the **edge of a job runner**. The recommended pattern today is a thin job class that calls `execute!`—which works but duplicates boilerplate across apps:
225
+
226
+ - JSON-safe argument hashes (string keys),
227
+ - Correlation id handoff across processes,
228
+ - Mapping permanent `fail!` outcomes to “do not retry” vs transient errors,
229
+ - Optional `idempotent_by` keys derived from job arguments.
230
+
231
+ Self-including `Sidekiq::Job` on the task class (documented in the blog) blurs “domain task” and “infrastructure adapter” and makes testing/no-Sidekiq environments heavier.
232
+
233
+ #### Intended function
234
+
235
+ Optional adapter layer (per backend or a small family of modules) that:
236
+
237
+ 1. Exposes familiar enqueue methods on the task class, e.g. `perform_async`, `perform_in`, `perform_at` (exact names aligned with the target gem where possible).
238
+ 2. At **enqueue time**, validates that the payload round-trips through the configured **serializer** (default `:json`): attribute keys, primitive types, no live objects. Fail fast in development if someone passes an `ActiveRecord` instance.
239
+ 3. At **perform time**, rebuilds context from the payload and calls `execute` / `execute!` with documented retry behavior hooks (e.g. register which `metadata[:code]` values are permanent).
240
+ 4. Supports **correlation id** propagation via an optional key in the payload and a documented callback or `settings` hook so telemetry lines up across web → job → nested job.
241
+ 5. Stays **optional**: no Sidekiq gem required in core; adapters live in optional requires or companion gems if needed.
242
+
243
+ #### Proposed API (sketch)
244
+
245
+ ```ruby
246
+ class Users::SendVerification < ApplicationTask
247
+ include CMDx::Adapters::Sidekiq # name TBD
248
+
249
+ sidekiq_options queue: :mailers, retry: 5
250
+ idempotent_by :user_id
251
+
252
+ required :user_id, coerce: :integer
253
+
254
+ def work
255
+ # ...
256
+ end
257
+ end
258
+
259
+ Users::SendVerification.perform_async(user_id: 42)
260
+ Users::SendVerification.perform_in(5.minutes, user_id: 42)
261
+ ```
262
+
263
+ #### Design notes
264
+
265
+ - Enqueue surface should mirror what teams already know from Sidekiq/ActiveJob to reduce migration friction.
266
+ - Document pairing with `idempotent_by` and `lock_with` for redelivered messages and concurrent workers.
267
+ - ActiveJob wrapper might be a generator template first, then a shared module.
268
+
269
+ #### Open questions
270
+
271
+ - One meta-adapter vs per-framework gems?
272
+ - Built-in mapping from `CMDx::Fault` / `metadata[:code]` to “discard” vs “retry” without per-app `rescue`?
273
+ - Workflow steps that enqueue child jobs—official pattern vs app responsibility?
274
+
275
+ #### Related
276
+
277
+ - [Background Jobs + CMDx](docs/blog/posts/real-world-cmdx-background-jobs.md)
278
+ - [Idempotency](docs/idempotency.md), [Locks](docs/locks.md)
279
+ - [Stores](docs/stores.md) — shared state across workers
280
+ - **Serializers** (above) — shared definition of serializable job/context payloads
281
+
282
+ ---
283
+
284
+ ## Workflows
285
+
286
+ ### Checkpoint / resume
287
+
288
+ **Status:** Idea
289
+ **Theme:** Persist workflow progress so a restarted run can skip completed step groups.
290
+
291
+ #### Problem
292
+
293
+ Long-running workflows (onboarding, fulfillment, provisioning) often span **multiple processes** or survive **worker crashes**. Today a workflow is a single `Pipeline` run on one context in one process:
294
+
295
+ - If the process dies after step 3 of 5, a retry typically **re-runs from the start** unless the app manually tracks state in the database.
296
+ - Parallel groups and rollback semantics make ad hoc “save state in `#work`” easy to get wrong.
297
+
298
+ #### Intended function
299
+
300
+ Checkpointing that:
301
+
302
+ 1. Identifies the workflow run with a stable **run id** (from context, `idempotent_by`, or explicit `checkpoint_by`—design TBD).
303
+ 2. After each **execution group** completes successfully (respecting `if:` / `unless:` skips), **dumps** a versioned snapshot of workflow `context` (plus resume metadata: completed group ids, skip/fail markers) via the **serializers registry** into `CMDx::Stores` or a pluggable backend.
304
+ 3. On a subsequent `execute` with the same run id, **loads** the blob through the same serializer (or fails gracefully if the format is unknown/corrupt), **hydrates** context, and **skips** groups already marked complete, then continues from the next pending group.
305
+ 4. Handles **failure** without advancing the checkpoint; failed groups remain retryable.
306
+ 5. Defines behavior for **parallel groups** (checkpoint only after merger succeeds), **conditional groups** (record skipped vs completed distinctly), and **rollback** (whether checkpoint is cleared or branched).
307
+
308
+ #### Proposed API (sketch)
309
+
310
+ ```ruby
311
+ class FulfillmentWorkflow < ApplicationTask
312
+ include CMDx::Workflow
313
+
314
+ idempotent_by :order_id # natural pairing: same order → same logical run
315
+
316
+ checkpoint true # or checkpoint_by: :order_id, ttl: 1.day, serializer: :json
317
+
318
+ task ValidateOrder
319
+ task ReserveInventory
320
+ tasks ChargePayment, CreateShipmentLabel, strategy: :parallel
321
+ task NotifyCustomer
322
+ end
323
+ ```
324
+
325
+ #### Design notes
326
+
327
+ - **Pairs with `idempotent_by`:** Duplicate job delivery can resume the same checkpointed run instead of duplicating side effects; docs should show recommended key shapes.
328
+ - **Serializers registry:** Checkpoint blobs use the same envelope as cache (serializer id + version); large payloads may use a custom registered serializer (compression, external blob pointer).
329
+ - **Store size and TTL:** Allowlists of persistable context keys enforced at dump time; core may store pointers when the serializer returns a short reference string.
330
+ - **Security:** Checkpoint blobs may contain PII; encryption and namespace isolation are app concerns but the API should not surprise authors.
331
+ - **Observability:** Telemetry events for checkpoint write, resume, and skip-per-group aid debugging stuck workflows.
332
+
333
+ #### Open questions
334
+
335
+ - Opt-in per workflow vs per-group `checkpoint: false` for sensitive steps?
336
+ - Migration when task classes change (envelope `format_version` vs per-workflow `checkpoint_version`)?
337
+ - Interaction with saga **rollback**—truncate checkpoint or fork a new run?
338
+
339
+ #### Related
340
+
341
+ - [Workflows](docs/workflows.md) — execution groups and pipeline
342
+ - [Stores](docs/stores.md) — “(eventually) workflow checkpoints”
343
+ - `CMDx::Pipeline` — group boundaries for persistence hooks
344
+ - `cache_result` (above) — shared store primitive patterns
345
+ - **Serializers** (above) — dump/load contract for checkpoint blobs
346
+
347
+ ---
348
+
349
+ ## How to contribute to this list
350
+
351
+ 1. Add or refine a section with **problem**, **intended function**, and **open questions**—not only a one-line bullet.
352
+ 2. When prototyping, add a link to a PR or discussion at the top of that section.
353
+ 3. Ship user-facing behavior through **docs + CHANGELOG**; trim or archive roadmap text here once the feature is released.
@@ -25,6 +25,20 @@ module CMDx
25
25
  :on_ko
26
26
  ].freeze
27
27
 
28
+ # Events fired after `around_execution` (work, rollback) finishes. Runtime
29
+ # passes the finalized {Signal} as the second callback argument so hooks can
30
+ # read `reason`, `cause`, `metadata`, `origin`, and `backtrace`.
31
+ POST_WORK_EVENTS = Set[
32
+ :after_execution,
33
+ :on_complete,
34
+ :on_interrupted,
35
+ :on_success,
36
+ :on_skipped,
37
+ :on_failed,
38
+ :on_ok,
39
+ :on_ko
40
+ ].freeze
41
+
28
42
  attr_reader :registry
29
43
 
30
44
  def initialize
@@ -122,20 +136,26 @@ module CMDx
122
136
  # Fires each callback registered for `event` against `task`. Skips any
123
137
  # callback whose `:if`/`:unless` gates fail.
124
138
  #
139
+ # Post-work events ({POST_WORK_EVENTS}) receive the finalized `signal` as
140
+ # their second argument (Symbol method, Proc, or `#call`). Pre-work events
141
+ # and `around_execution` omit it.
142
+ #
125
143
  # @param event [Symbol]
126
144
  # @param task [Task]
145
+ # @param signal [Signal, nil] finalized halt token for post-work events
127
146
  # @return [void]
128
147
  # @raise [ArgumentError] when a callback is neither a Symbol nor responds to `#call`
129
- def process(event, task)
148
+ def process(event, task, signal = nil)
130
149
  return if empty?
131
150
 
132
151
  callbacks = registry[event]
133
152
  return if callbacks.nil? || callbacks.empty?
134
153
 
154
+ post_work = POST_WORK_EVENTS.include?(event)
135
155
  callbacks.each do |callable, options|
136
156
  next unless Util.satisfied?(options[:if], options[:unless], task)
137
157
 
138
- invoke(callable, task)
158
+ invoke(callable, task, signal: post_work ? signal : nil)
139
159
  end
140
160
  end
141
161
 
@@ -167,7 +187,7 @@ module CMDx
167
187
  succ.call
168
188
  end
169
189
 
170
- invoke(callable, task, cont, &cont)
190
+ invoke(callable, task, continuation: cont, &cont)
171
191
 
172
192
  called || raise(CallbackError, <<~MSG.chomp)
173
193
  #{event} callback did not invoke its continuation.
@@ -179,14 +199,25 @@ module CMDx
179
199
 
180
200
  private
181
201
 
182
- def invoke(callable, task, *extras, &)
202
+ def invoke(callable, task, continuation: nil, signal: nil, &block)
183
203
  case callable
184
204
  when Symbol
185
- task.send(callable, &)
205
+ if signal
206
+ task.send(callable, signal, &block)
207
+ else
208
+ task.send(callable, &block)
209
+ end
186
210
  when Proc
187
- task.instance_exec(task, *extras, &callable)
211
+ extras = continuation ? [continuation] : []
212
+ if signal
213
+ task.instance_exec(task, signal, *extras, &callable)
214
+ else
215
+ task.instance_exec(task, *extras, &callable)
216
+ end
188
217
  else
189
- return callable.call(task, *extras) if callable.respond_to?(:call)
218
+ args = signal ? [task, signal] : [task]
219
+ args << continuation if continuation
220
+ return callable.call(*args) if callable.respond_to?(:call)
190
221
 
191
222
  raise ArgumentError, <<~MSG.chomp
192
223
  callback must be a Symbol, Proc, or respond to #call (got #{callable.class}).