shifty 0.5.0 → 0.6.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.
@@ -0,0 +1,590 @@
1
+ **Status:** Implemented in 0.6.0 (see [feature-implementation-plan.md](feature-implementation-plan.md))
2
+ **Target:** 0.6.0 (pre-1.0 minor carrying the breaking change)
3
+ **Author:** Joel Helbling
4
+ **Last updated:** 2026-07-10
5
+
6
+ > **Post-implementation corrections.** Empirical spikes during Phase 1
7
+ > falsified three mechanism claims below; the shipped code follows the
8
+ > corrected behavior (design intent unchanged):
9
+ >
10
+ > 1. **§3.2/§10.1:** `:isolated` uses a **Marshal round-trip**, not
11
+ > `Ractor.make_shareable(copy: true)` — the latter returns a *frozen*
12
+ > copy and cannot provide the mutable scratch copy the contract
13
+ > promises. Consequently `:hardened` → `:isolated` preserves the exact
14
+ > mechanism as well as the semantics.
15
+ > 2. **§3.3/§10.1:** `make_shareable` does **not** reject IO handles or
16
+ > singleton-methoded objects — it silently freezes a live IO *in
17
+ > place*, process-wide (and `copy: true` leaks a file descriptor). The
18
+ > implementation therefore detects top-level IO proactively and raises
19
+ > `UnshareableValue` before calling `make_shareable`. Only Proc and
20
+ > `Enumerator::Lazy` (and StringIO) are rejected naturally.
21
+ > 3. **§5.3/§11.4:** `PolicyConflict` was dropped entirely — with worker
22
+ > declarations authoritative and pipeline policy default-only, no
23
+ > violable composition rule exists. Reopen alongside a "strict Gang".
24
+ >
25
+ > Also: §5.4's `Worker#task=` never existed; `#freeze!`'s motivation is
26
+ > `supply=`/Roster rewiring. §8.2's amortization claim was validated by
27
+ > benchmarks (`benchmark/RESULTS.md`): steady-state `:frozen` ≈ 71ns per
28
+ > handoff, independent of value size.
29
+ ---
30
+
31
+ # Handoff Immutability Policies
32
+
33
+ ## 1. Motivation
34
+
35
+ Shifty's core strength is that individual workers are simple to reason about: each
36
+ worker is an isolated unit of work with private state held in closure scope, connected
37
+ to its neighbors only by the values it receives and hands off. But that strength has a
38
+ shadow. The *connascence buried in the values passed between workers* remains
39
+ unmanaged. Every handoff is a shared Ruby object reference, which means:
40
+
41
+ - A worker four steps into a pipeline can mutate a value (`<<`, `map!`, `merge!`,
42
+ attribute assignment) and silently corrupt what workers five through nine observe.
43
+ - Because the escalator model moves each value through *every* worker before the next
44
+ value starts, the symptom of such a mutation typically surfaces far downstream from
45
+ its cause. Failure locality is poor; debugging is archaeology.
46
+ - Side workers — whose entire contract is "observe, don't modify" — cannot actually be
47
+ held to that contract. The current `:hardened` mode acknowledges this by handing side
48
+ workers a `Marshal.load(Marshal.dump(value))` deep copy, at significant cost.
49
+
50
+ Immutability addresses Shifty exactly where it is weakest. Workers stay easy to reason
51
+ about *individually*; immutable handoffs make the *data space between them* equally
52
+ tractable. Mutation bugs stop being possible-but-invisible and become either impossible
53
+ (isolated copies) or immediately loud (`FrozenError` at the offending worker).
54
+
55
+ Strong typing (e.g. Sorbet) would extend these guarantees further, but is a separate
56
+ concern and a separate developer choice. It is explicitly out of scope here (§12).
57
+
58
+ ---
59
+
60
+ ## 2. Summary of the Change
61
+
62
+ 1. Introduce a **handoff policy** governing how each value crosses a worker boundary.
63
+ 2. Three policies: **`:frozen`** (new default), **`:isolated`**, and **`:shared`**.
64
+ 3. Policy is declarable at the **worker** level and at the **pipeline/Gang** level;
65
+ worker declarations take precedence (§5).
66
+ 4. The existing `:hardened` option is subsumed by `:isolated` and deprecated (§10).
67
+ 5. Raw `FrozenError`s arising from policy violations are caught by the framework and
68
+ re-raised as `Shifty::PolicyViolation` with pipeline-aware diagnostics (§6).
69
+ 6. A test harness and RSpec helpers ensure unit tests exercise workers under the same
70
+ policy the pipeline will (§9).
71
+
72
+ This is a breaking change in default behavior and warrants a major version bump.
73
+
74
+ ---
75
+
76
+ ## 3. The Three Policies
77
+
78
+ ### 3.1 `:frozen` — the new default
79
+
80
+ The value is **deeply frozen** at handoff via `Ractor.make_shareable(value)` and passed
81
+ **by reference**. Zero copies. Any attempt to mutate the value (or anything reachable
82
+ from it) raises immediately, at the worker that misbehaved.
83
+
84
+ - **Guarantee:** no worker can mutate a value observed by any other worker.
85
+ - **Cost:** freeze traversal only; no copying, no extra garbage. Amortized cost is
86
+ proportional to *newly created* objects, not graph size (§8.2).
87
+ - **Developer contract:** tasks must be non-destructive. "Apparent mutation" is
88
+ expressed as copy-on-write: `value.with(...)`, `arr + [x]`, `str + "suffix"`,
89
+ `hash.merge(...)` (§7).
90
+
91
+ ### 3.2 `:isolated` — compatibility / scratch-copy mode
92
+
93
+ The worker's task receives a **deep, mutable copy** of the value, produced by
94
+ `Ractor.make_shareable(value, copy: true)` where available (falling back to
95
+ `Marshal.load(Marshal.dump(value))`). The task may mutate its copy freely — `<<` and
96
+ friends work exactly as in classic Shifty code — but nothing leaks back to the
97
+ upstream reference (the supplying worker's closure state, a source's recirculating
98
+ value, etc.). Whatever the task returns/hands off is what flows downstream, mutations
99
+ included.
100
+
101
+ - **Guarantee:** upstream references are protected; task-local mutation is invisible
102
+ outside the worker.
103
+ - **Cost:** full deep copy of the value graph **per worker, per value**, plus the
104
+ corresponding GC pressure. This is why it is not the default (§8.1).
105
+ - **Use cases:** mutation-heavy legacy task code not worth rewriting; side workers
106
+ (this is precisely the old `:hardened` semantics, generalized); untrusted or
107
+ third-party task procs.
108
+ - **Note for side workers:** the return value of a side worker is discarded, so under
109
+ `:isolated` a side worker's mutations simply evaporate — the behavior the
110
+ documentation always wished for.
111
+
112
+ ### 3.3 `:shared` — the escape hatch
113
+
114
+ The raw object reference is passed through untouched. This is today's default
115
+ behavior, renamed to say what it actually is.
116
+
117
+ There are two legitimate reasons to reach for `:shared`, and the name is chosen to
118
+ cover both:
119
+
120
+ 1. **Intentional in-place mutation** of a value that downstream workers are meant to
121
+ observe.
122
+ 2. **Uncopyable / unfreezable values**: IO handles, sockets, lazy enumerators, procs,
123
+ objects with singleton methods, or anything else that `make_shareable` (or Marshal)
124
+ rejects. Sometimes the developer is not asking permission to mutate; she is asking
125
+ for pass-by-reference.
126
+
127
+ - **Guarantee:** none. The framework provides no protection on this boundary.
128
+ - **Cost:** zero.
129
+
130
+ ### 3.4 Policy comparison
131
+
132
+ | | `:frozen` (default) | `:isolated` | `:shared` |
133
+ |---|---|---|---|
134
+ | What the task receives | frozen shared reference | private mutable deep copy | raw shared reference |
135
+ | Upstream protected from mutation | yes (mutation raises) | yes (mutation is local) | no |
136
+ | Mutation in task code | raises `PolicyViolation` | works, stays local | works, leaks |
137
+ | Copying per handoff | none | full graph | none |
138
+ | Freeze traversal per handoff | delta only (amortized) | none | none |
139
+ | GC pressure | minimal | high | none |
140
+ | Handles unshareable values (IO, procs, …) | no — raises at handoff | no — raises at handoff | yes |
141
+ | Failure mode of incorrect task code | loud, local, immediate | silent no-op upstream | silent corruption downstream |
142
+
143
+ ---
144
+
145
+ ## 4. Why `:frozen` Is the Right Default
146
+
147
+ ### 4.1 The candidates
148
+
149
+ - **`:shared` (status quo):** preserves silent-corruption bugs; rejected.
150
+ - **`:isolated`:** attractive because existing task code keeps working verbatim — but
151
+ it deep-copies the entire value graph at *every* boundary. A nine-worker pipeline
152
+ copies every value nine times and generates nine graphs of garbage. For token-sized
153
+ values this is noise; for parsed documents or large collections it is a serious,
154
+ hard-to-work-around tax. Defaults should not carry O(graph) costs per hop.
155
+ (This is also, historically, why `:hardened` was never made the default: the
156
+ Marshal round-trip was too expensive. `make_shareable(copy: true)` is cheaper, but
157
+ it is the same complexity class.)
158
+ - **`:frozen`:** zero-copy, and its freeze cost amortizes to the size of the *change*
159
+ rather than the size of the value (§8.2). It is the only policy that is
160
+ simultaneously safe and cheap at scale.
161
+
162
+ ### 4.2 The trade-off being accepted
163
+
164
+ `:frozen` will briefly surprise developers whose task code mutates its input. That
165
+ surprise is deliberate, and it is a *well-behaved* surprise:
166
+
167
+ - It arrives **immediately**, at the exact worker that misbehaved — not downstream.
168
+ - It arrives as a rich `PolicyViolation` naming the receiver and offering two
169
+ documented exits (rewrite non-destructively, or declare `:isolated`/`:shared` on
170
+ that worker) (§6).
171
+ - An early, loud, explained error is strictly preferable to cumbersome performance
172
+ problems (an `:isolated` default) or silent data corruption (a `:shared` default).
173
+
174
+ ### 4.3 The synergy that makes it pleasant
175
+
176
+ `:frozen` as default and copy-on-write as idiom are not two separate decisions.
177
+ Structural sharing (`Data#with` copying member *references*, `arr + [x]` sharing
178
+ elements) is only safe because shared substructure is frozen; and copy-on-write is
179
+ what makes frozen values ergonomic. Each half makes the other work (§7, §8).
180
+
181
+ ---
182
+
183
+ ## 5. API Design
184
+
185
+ ### 5.1 Declaring policy
186
+
187
+ Policy may be declared at three levels, from narrowest to widest:
188
+
189
+ ```ruby
190
+ # 1. Worker level — part of the worker's contract
191
+ worker = Shifty::Worker.new(policy: :isolated) { |v| v << transform(v) }
192
+
193
+ # DSL flavors (names illustrative):
194
+ side_worker(policy: :shared) { |v| logger.info(v) }
195
+
196
+ # 2. Pipeline / composition level — default for undeclared workers
197
+ pipeline = (source | parser | enricher | sink).with_policy(:frozen)
198
+
199
+ # 3. Global default — :frozen out of the box
200
+ Shifty.configure { |c| c.default_policy = :frozen }
201
+ ```
202
+
203
+ ### 5.2 Precedence: worker beats pipeline beats global
204
+
205
+ A worker-level declaration is part of that worker's **contract** and always wins.
206
+ Pipeline-level policy is a *default applied to undeclared workers*, not an override.
207
+ This rule is what makes testing reliable (§9): any harness that runs a worker through
208
+ the framework automatically exercises the policy that production will.
209
+
210
+ ### 5.3 Build-time validation
211
+
212
+ Because policy is part of the worker contract, mismatches can be detected at
213
+ composition time — at the `|`, not mid-stream:
214
+
215
+ - A worker declaring `:shared` (e.g. "I pass an IO handle downstream") composed into a
216
+ pipeline whose policy configuration forbids it can raise
217
+ `Shifty::PolicyConflict` when the pipeline is assembled.
218
+ - Exact strictness semantics (whether pipelines may *forbid* worker-level loosening)
219
+ is an open question (§11.4); the initial implementation treats worker declarations
220
+ as authoritative and pipeline policy as default-only.
221
+
222
+ ### 5.4 Freezing the topology (companion change)
223
+
224
+ The same intentionality argument applies to the pipeline itself: `Worker#task=` and
225
+ `Worker#supply=` allow a fully assembled pipeline to be rewired mid-stream. This
226
+ change adds an optional `#freeze!` on the assembled chain so that "the pipeline you
227
+ composed is the pipeline that runs" can be a guarantee rather than a convention.
228
+ (Independent of handoff policy; included in the same major version.)
229
+
230
+ ### 5.5 Optional worker naming for diagnostics
231
+
232
+ Workers gain an optional `name:` (in addition to existing tags) used purely in error
233
+ messages and diagnostics, so a `PolicyViolation` can say *which* of nine anonymous
234
+ workers raised.
235
+
236
+ ---
237
+
238
+ ## 6. Errors and Diagnostics
239
+
240
+ ### 6.1 The problem
241
+
242
+ A developer changes a policy without changing task code (or vice versa), and the code
243
+ becomes *incorrect*. Raw `FrozenError`s from deep inside a task, surfacing in a test
244
+ run, do not point anywhere useful. The framework owns every task call site, so it can
245
+ do much better.
246
+
247
+ ### 6.2 `Shifty::PolicyViolation`
248
+
249
+ ```ruby
250
+ def perform_task(value)
251
+ task.call(value)
252
+ rescue FrozenError => e
253
+ raise PolicyViolation.new(
254
+ worker: self, # includes name/tags for locating it
255
+ policy: effective_policy,
256
+ receiver: e.receiver, # the object the task tried to mutate
257
+ value: value,
258
+ cause: e # never mask the original
259
+ )
260
+ end
261
+ ```
262
+
263
+ Key details:
264
+
265
+ - **`FrozenError#receiver`** identifies *which object* the task tried to mutate. This
266
+ distinguishes "you mutated the handed-off value (or something reachable from it)"
267
+ from an unrelated frozen-string-literal error in the task's own code. Heuristic:
268
+ report whether `receiver.equal?(value)` or receiver appears reachable from `value`;
269
+ when unclear, report both objects and let the developer judge.
270
+ - **Message content** (illustrative):
271
+
272
+ > Worker `enricher` (tags: `[:etl]`) received its value under the `:frozen` handoff
273
+ > policy, and its task attempted to mutate an instance of `Array`.
274
+ >
275
+ > Either make the task non-destructive — e.g. `map` instead of `map!`,
276
+ > `value.with(...)`, `arr + [x]` — or declare a different policy on this worker:
277
+ > `policy: :isolated` (task works on a private scratch copy) or `policy: :shared`
278
+ > (raw reference; no protection).
279
+
280
+ - Unshareable-value failures at the handoff itself (IO, procs, singleton-methoded
281
+ objects under `:frozen` or `:isolated`) raise a parallel
282
+ `Shifty::UnshareableValue` error: *"this value cannot be frozen/copied; declare
283
+ `:shared` on this worker, or restructure the value."*
284
+
285
+ ### 6.3 The silent-loosening asymmetry
286
+
287
+ Error wrapping only catches motion in the *strict* direction. Moving **looser** —
288
+ `:frozen`/`:isolated` → `:shared` — fails silently: a task that was harmlessly
289
+ mutating its private copy (or that would have raised) now mutates the live shared
290
+ value. No exception will ever fire. This asymmetry must be documented prominently,
291
+ and it is the primary motivation for the mutation detector:
292
+
293
+ ### 6.4 Mutation detector (opt-in diagnostic mode)
294
+
295
+ A development/test-only mode in which the framework hands each task a mutable deep
296
+ copy, then compares the copy before and after invocation (via `Marshal.dump`
297
+ comparison or recursive digest) and reports:
298
+
299
+ > Worker `enricher`'s task **mutates its input**. It is only correct under
300
+ > `:isolated` (mutation stays local) or `:shared` (mutation is intentional and
301
+ > observed downstream). It will raise under `:frozen`.
302
+
303
+ This surfaces mutation even when the current policy *permits* it — exactly the
304
+ information a developer needs **before** flipping a policy, not after. It also powers
305
+ the `not_to mutate_input` test matcher (§9.3). Never enabled in production paths
306
+ (it costs a deep copy per handoff plus comparison).
307
+
308
+ ---
309
+
310
+ ## 7. Coding Idioms Under `:frozen`
311
+
312
+ Documentation (README + a dedicated guide) should establish these patterns:
313
+
314
+ ### 7.1 Copy-on-write instead of mutation
315
+
316
+ | Mutating (raises under `:frozen`) | Non-destructive equivalent |
317
+ |---|---|
318
+ | `str << "x"`, `str.upcase!` | `str + "x"`, `str.upcase` |
319
+ | `arr << x`, `arr.map!` | `arr + [x]`, `arr.map` |
320
+ | `hash[k] = v`, `hash.merge!` | `hash.merge(k => v)` |
321
+ | `obj.attr = v` | `obj.with(attr: v)` (see below) |
322
+
323
+ ### 7.2 `Data.define` as the recommended value envelope
324
+
325
+ Ruby 3.2+ `Data` classes are immutable by construction and ship copy-on-update:
326
+
327
+ ```ruby
328
+ Token = Data.define(:payload, :meta)
329
+
330
+ # in a worker task:
331
+ ->(v) { v.with(payload: enrich(v.payload)) }
332
+ ```
333
+
334
+ `#with` allocates one new outer object and copies member *references*; unchanged
335
+ members are structurally shared. This is safe precisely because everything is frozen.
336
+ If Shifty later grows an official work-item envelope (provenance, batch metadata),
337
+ `Data` is the substrate.
338
+
339
+ ### 7.3 Mutable within, immutable between
340
+
341
+ Closure state remains fully mutable — that is Shifty's design and its selling point
342
+ for stateful workers, and nothing about it changes. Workers that *build* values
343
+ (sources; batch/trailing workers accumulating in closure scope) may mutate freely
344
+ during construction; the freeze happens at handoff. The framework thereby enforces
345
+ the functional-core boundary exactly where Shifty always drew it conceptually:
346
+
347
+ ```ruby
348
+ source_worker do
349
+ buffer = [] # closure state: mutable, private, fine
350
+ while (line = io.gets)
351
+ buffer << line # construction: mutate freely
352
+ handoff buffer.join if buffer.size == 10 # handoff: frozen from here on
353
+ end
354
+ end
355
+ ```
356
+
357
+ One consequence worth a doc callout: a builder that hands off an object and *keeps a
358
+ reference to it* (e.g. hands off `buffer` itself, then keeps appending) will raise on
359
+ the next append under `:frozen` — which is correct, and exactly the aliasing bug the
360
+ policy exists to catch. Hand off a snapshot (`buffer.dup`, `buffer.join`, `#with`)
361
+ when the builder intends to keep building.
362
+
363
+ ### 7.4 Heavy accumulation: persistent data structures (optional)
364
+
365
+ For genuinely large collections under frequent update, `arr + [x]`'s O(n) pointer
366
+ copy can pinch. The **immutable-ruby** gem's persistent `Vector`/`Hash` offer
367
+ O(log n) structurally-shared updates. This is a documented optimization for users,
368
+ **not** a Shifty dependency.
369
+
370
+ ---
371
+
372
+ ## 8. Performance Analysis
373
+
374
+ ### 8.1 Complexity per handoff
375
+
376
+ | Policy | CPU per handoff | Allocation per handoff |
377
+ |---|---|---|
378
+ | `:shared` | O(1) | none |
379
+ | `:frozen` | O(new objects since last freeze) — see 8.2 | none |
380
+ | `:isolated` | O(entire value graph) | entire value graph (GC pressure) |
381
+
382
+ Ordering: `:shared` ≤ `:frozen` (with copy-on-write idioms, delta-proportional)
383
+ ≪ `:isolated` (whole-graph-proportional, per boundary). In practice the GC pressure
384
+ of `:isolated` — a full graph of garbage per worker per value — is often a bigger
385
+ real-world cost than the copy CPU itself.
386
+
387
+ ### 8.2 Why `:frozen` amortizes
388
+
389
+ MRI marks objects shareable once `Ractor.make_shareable` has blessed them, and the
390
+ traversal short-circuits on already-shareable subgraphs. In a `:frozen` pipeline the
391
+ *first* handoff pays a full traversal of the value; every subsequent handoff
392
+ traverses only objects created since the previous one. Combined with copy-on-write
393
+ task code (which by definition creates only delta-sized new structure), per-boundary
394
+ freeze cost is proportional to the change, not the value.
395
+
396
+ ### 8.3 `Ractor.shareable?` fast path (open design point)
397
+
398
+ If an incoming value is already shareable, it is deeply frozen and *nobody* can
399
+ mutate it — isolation is trivially satisfied without copying. `:isolated` could
400
+ exploit this and skip the copy. Wrinkle: the task then receives a frozen object where
401
+ it expected a mutable scratch copy. Options: (a) accept "you get either a mutable
402
+ copy or an already-immutable original" as the `:isolated` contract; (b) don't
403
+ optimize; (c) add an `:isolated_frozen` variant. Decision deferred pending benchmarks
404
+ (§13); initial implementation takes (b) for contract simplicity.
405
+
406
+ ### 8.4 Benchmarks to produce before release
407
+
408
+ - `:shared` vs `:frozen` vs `:isolated` vs legacy `:hardened` (Marshal) across value
409
+ shapes: small token, mid-size hash/array, large parsed document, deep nesting.
410
+ - First-handoff vs steady-state cost under `:frozen` (verify the amortization claim).
411
+ - GC statistics (allocations, minor/major GC counts) per policy.
412
+ - `Data#with` vs `make_shareable(copy: true)` for representative update patterns.
413
+
414
+ ---
415
+
416
+ ## 9. Testing Story
417
+
418
+ ### 9.1 The parity problem
419
+
420
+ A pipeline runs `:frozen`; a unit test exercises the worker's raw proc
421
+ (`worker.task.call(input)`) with an ordinary mutable object. The test passes; the
422
+ pipeline raises. Three reinforcing moves close this gap:
423
+
424
+ ### 9.2 Worker-level policy declarations win (§5.2)
425
+
426
+ Since the worker carries its policy, any test that runs the worker *through the
427
+ framework* automatically exercises production semantics. With `:frozen` as the
428
+ global default, the common case requires no test configuration at all.
429
+
430
+ ### 9.3 Make the framework path the convenient path
431
+
432
+ ```ruby
433
+ # Test harness — uses the worker's declared/effective policy by default
434
+ outputs = Shifty::Testing.run(worker, inputs: [a, b, c])
435
+
436
+ # Explicit override for policy-matrix testing
437
+ outputs = Shifty::Testing.run(worker, inputs: [a], policy: :frozen)
438
+ ```
439
+
440
+ RSpec sugar:
441
+
442
+ ```ruby
443
+ it_behaves_like "a policy-safe worker" # runs task against deeply frozen input
444
+ expect(worker).not_to mutate_input # built on the mutation detector (§6.4)
445
+ ```
446
+
447
+ ### 9.4 Test at the ceiling (monotonicity)
448
+
449
+ Policy safety is (almost) a one-way ratchet: a task correct on deeply frozen input is
450
+ correct under every policy; the reverse does not hold. Therefore the harness default
451
+ — and the documented guidance — is to test under `:frozen` unless the worker
452
+ explicitly declares mutation as part of its design. Consequences:
453
+
454
+ - Loosening a pipeline's policy can never break a ceiling-tested worker.
455
+ - Tightening is the only breaking direction, and tightening fails loudly with
456
+ `PolicyViolation` diagnostics.
457
+
458
+ **Caveat (the "almost"):** `:isolated` hands the task a *copy*, so a worker
459
+ depending on object **identity** (rare; conceivable with identity-keyed caches)
460
+ behaves differently there. This is the one spot where strict-passing does not imply
461
+ lax-passing; document it.
462
+
463
+ ---
464
+
465
+ ## 10. Migration Guide (Breaking Changes)
466
+
467
+ ### 10.1 What breaks
468
+
469
+ 1. **Default behavior changes** from raw shared references (`:shared`) to deeply
470
+ frozen references (`:frozen`). Any task that mutates its input raises
471
+ `PolicyViolation` on first contact.
472
+ 2. **Unshareable values** (IO handles, sockets, procs, lazy enumerators, objects
473
+ with singleton methods) can no longer cross a default-policy boundary; they raise
474
+ `UnshareableValue` at handoff. These pipelines must declare `:shared` on the
475
+ affected workers.
476
+ 3. **`:hardened` is deprecated**, mapped to `:isolated` with a deprecation warning
477
+ for one major version, then removed. Semantics are preserved (deep private copy);
478
+ the mechanism changes from Marshal round-trip to
479
+ `make_shareable(copy: true)` where available. Note: the two mechanisms reject
480
+ slightly different sets of objects (their failure sets overlap but are not
481
+ identical) — this is covered by specs (§13) and called out in the CHANGELOG.
482
+
483
+ ### 10.2 Migration paths, in order of preference
484
+
485
+ 1. **Rewrite tasks non-destructively** (§7). Usually a small diff (`map!`→`map`,
486
+ `<<`→`+`, `merge!`→`merge`); the `PolicyViolation` message names the object and
487
+ the worker.
488
+ 2. **Declare `:isolated`** on mutation-heavy workers not worth rewriting. Correctness
489
+ preserved, cost localized to those workers.
490
+ 3. **Declare `:shared`** where pass-by-reference is genuinely required (uncopyable
491
+ values, intentional shared mutation). This restores today's exact semantics per
492
+ worker.
493
+ 4. **Blanket opt-out** for large legacy codebases:
494
+ `Shifty.configure { |c| c.default_policy = :shared }` reproduces current behavior
495
+ globally, letting teams migrate worker-by-worker.
496
+
497
+ ### 10.3 Migration tooling
498
+
499
+ Run the mutation detector (§6.4) across an existing suite *before* upgrading: it
500
+ inventories which workers mutate input under the old default, i.e. exactly which
501
+ workers need path 1, 2, or 3.
502
+
503
+ ---
504
+
505
+ ## 11. Edge Cases and Open Questions
506
+
507
+ 1. **Unshareable values under `:frozen`/`:isolated`** — raise `UnshareableValue`
508
+ with guidance (declare `:shared`, or restructure). Should common cases (e.g.
509
+ `Enumerator::Lazy`) get tailored messages? *(Nice-to-have.)*
510
+ 2. **Object identity under `:isolated`** — copies break identity-based logic (§9.4).
511
+ Documented; no mitigation planned.
512
+ 3. **`shareable?` fast path for `:isolated`** — deferred; see §8.3.
513
+ 4. **May a pipeline *forbid* worker-level `:shared`?** — e.g. a "strict Gang" for
514
+ untrusted workers. Initial answer: no; worker contract wins (§5.2). Revisit if a
515
+ concrete need appears.
516
+ 5. **Frozen values and `#with`-less objects** — plain objects lack `#with`. Guidance:
517
+ prefer `Data`; otherwise `clone(freeze: true)` + constructor patterns. Should
518
+ Shifty ship a tiny `Shifty::Value` mixin? *(Probably not; stay unopinionated.)*
519
+ 6. **Ractor future** — `:frozen`'s shareable values are exactly what Ractor
520
+ boundaries require. This change is deliberately Ractor-*compatible* but
521
+ Ractor-based workers remain out of scope. Fibers cannot cross Ractors; a
522
+ Ractor-backed worker would be a distinct worker type in a future effort.
523
+ 7. **Ruby version floor** — `Ractor.make_shareable` requires Ruby ≥ 3.0;
524
+ `FrozenError#receiver` requires ≥ 2.7; `Data` idioms require ≥ 3.2. Proposal: set
525
+ the gem's floor at 3.2 for this major version (aligns docs, `Data`, and mature
526
+ `make_shareable` behavior). Marshal fallback then exists only for exotic
527
+ platforms lacking Ractor support, if any are still targeted.
528
+
529
+ ---
530
+
531
+ ## 12. Out of Scope
532
+
533
+ - **Static/strong typing** (Sorbet, RBS): would extend data-space guarantees further,
534
+ but is an orthogonal, separate developer choice. Nothing in this design should
535
+ preclude it; `Data`-based envelopes are Sorbet-friendly if users go there.
536
+ - **Ractor-based parallel workers**: this change lays the groundwork (shareable
537
+ values by default) but introduces no Ractors.
538
+ - **Persistent data structures as a dependency**: immutable-ruby remains a
539
+ documented user-side optimization only.
540
+ - **Enforcing purity of closure state**: worker-internal state is intentionally
541
+ mutable; that is Shifty's model. Only *handoffs* are governed.
542
+
543
+ ---
544
+
545
+ ## 13. Implementation Plan
546
+
547
+ **Phase 1 — mechanism**
548
+ - Handoff policy plumbing: worker attribute, pipeline default, global config,
549
+ precedence resolution.
550
+ - `:frozen` (make_shareable), `:isolated` (make_shareable copy, Marshal fallback),
551
+ `:shared` implementations at the handoff site.
552
+ - `PolicyViolation` / `UnshareableValue` wrapping with receiver heuristics and
553
+ worker `name:` support.
554
+ - Specs: policy matrix × value shapes, including the Marshal-vs-make_shareable
555
+ failure-set edge cases (procs, IO, singleton methods, lazy enumerators).
556
+
557
+ **Phase 2 — diagnostics & testing**
558
+ - Mutation detector mode.
559
+ - `Shifty::Testing.run` harness; RSpec shared examples and `mutate_input` matcher.
560
+ - Build-time `PolicyConflict` validation at composition.
561
+
562
+ **Phase 3 — performance & polish**
563
+ - Benchmark suite (§8.4); publish results in docs.
564
+ - Decide `shareable?` fast-path question (§8.3) from benchmark data.
565
+ - `#freeze!` for pipeline topology (§5.4).
566
+
567
+ **Phase 4 — release**
568
+ - Migration guide, CHANGELOG, README rewrite of the side-worker/hardened sections.
569
+ - Reconcile the concurrency documentation introduced by PR #26
570
+ (`README.md` "Concurrency Model", `docs/use_cases.md`, and the thread-safety
571
+ comments in `lib/shifty/worker.rb`): the interim edits there forward-reference
572
+ this plan, but on release they must be updated from "planned" to shipped —
573
+ fold copy-on-write idioms (§7) into `use_cases.md`'s examples, and confirm the
574
+ single-threading/immutability boundary and Ractor-compatibility framing (§11.6)
575
+ are stated as current behavior rather than future work.
576
+ - `:hardened` deprecation shim.
577
+ - Major version release.
578
+
579
+ ---
580
+
581
+ ## Appendix A — One-paragraph rationale (for README)
582
+
583
+ > Shifty workers are easy to reason about because each one is isolated; the values
584
+ > flowing *between* them were, until now, the un-governed part of the system. As of
585
+ > vNEXT, values are deeply frozen at every handoff by default. Workers that need a
586
+ > private scratch copy can declare `policy: :isolated`; workers that genuinely need
587
+ > shared mutable references can declare `policy: :shared`. Mutation bugs that used to
588
+ > surface as mysterious downstream corruption now either cannot happen or raise
589
+ > immediately at the worker responsible — with an error message that tells you
590
+ > exactly what to do about it.
@@ -1,3 +1,7 @@
1
+ > This page covers the raw `Shifty::Worker` API. The
2
+ > [wiki's Worker-Types page](https://github.com/joelhelbling/shifty/wiki/Worker-Types)
3
+ > covers every DSL worker and their handoff-policy behavior.
4
+
1
5
  # Shifty::Worker
2
6
 
3
7
  _The workhorse of the Shifty framework._
@@ -31,25 +35,10 @@ relay_worker = Shifty::Worker.new(task: capitalizer, supply: source_worker)
31
35
  relay_worker.shift #=> 'Hulk'
32
36
  ```
33
37
 
34
- A worker also has an accessor for its @task:
35
-
36
- ```ruby
37
- doofusizer = Proc.new { |name| name.gsub(/u/, 'oo') }
38
- relay_worker.task = doofusizer
39
-
40
- relay_worker.shift #=> 'hoolk'
41
- ```
42
-
43
- And finally, you can provide a task by directly overriding the
44
- worker's #task instance method:
45
-
46
- ```ruby
47
- def relay_worker.task(name)
48
- name.to_sym
49
- end
50
-
51
- relay_worker.shift #=> :hulk
52
- ```
38
+ A worker's task is fixed at construction there is no `task=`
39
+ accessor. (And as of 0.6.0, `#freeze!` can lock the rest of the
40
+ topology down too, so the pipeline you composed is the pipeline
41
+ that runs.)
53
42
 
54
43
  Even workers without a task have a task; all workers actually come
55
44
  with a default task which simply passes on the received value unchanged: