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,196 @@
1
+ # Round 1 verbatim specialist outputs (reference for synthesis & implementation)
2
+
3
+ ## software-architect (A1–A7)
4
+
5
+ **A1: Apply policy at INGRESS (the consuming worker's pull), via a single intake seam plus a thin policy-applying supply proxy for in-task shifts.**
6
+ Sites today: worker.rb:64 `value = supply&.shift`; worker.rb:66 `Fiber.yield @task.call(...)`; in-task `supply.shift` in filter_worker/batch_worker/trailing_worker (dsl.rb:56,86,122); direct `handoff`/Fiber.yield in source_worker/splitter_worker (dsl.rb:17,105,133). Make policy a property of *how a worker receives its value* (§6.2 names the receiving worker). Producer-side direct handoffs need nothing — their outputs are frozen at the downstream consumer's intake. Sketch:
7
+
8
+ ```ruby
9
+ # worker.rb
10
+ def workflow
11
+ @machine ||= Fiber.new do
12
+ loop do
13
+ value = intake(@supply&.shift) # primary intake seam
14
+ if criteria_passes?
15
+ Fiber.yield perform_task(value, policy_supply)
16
+ else
17
+ Fiber.yield value # already policy-applied
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+ def intake(value)
24
+ effective_policy.apply(value, worker: self) # freeze / copy / passthrough
25
+ end
26
+
27
+ def perform_task(value, supply_proxy)
28
+ @task.call(value, supply_proxy, @context)
29
+ rescue FrozenError => e
30
+ raise PolicyViolation.new(worker: self, policy: effective_policy,
31
+ receiver: e.receiver, value: value, cause: e)
32
+ end
33
+
34
+ def policy_supply
35
+ @policy_supply ||= PolicySupply.new(@supply, self) # #shift => intake(supply.shift)
36
+ end
37
+ ```
38
+
39
+ `PolicySupply` proxy replaces the raw `supply` as the task's 2nd arg, so filter/batch/trailing's in-task `supply.shift` transparently applies the same policy with zero task-body changes (three concrete uses today — Rule of Three met). §7.3 aliasing still caught: make_shareable freezes the actual object in place, so the producer's later mutation raises at the producer. Rejected simpler version: freeze at the single egress site — misses in-task shifts and makes producer policy govern the boundary, contradicting §5.2.
40
+
41
+ **A2: `Shifty::Policy` module of three frozen singleton lambdas behind `resolve(name)`; precedence resolution stays inline on Worker.**
42
+
43
+ ```ruby
44
+ # lib/shifty/policy.rb
45
+ module Shifty
46
+ module Policy
47
+ Frozen = ->(value, worker:) { Ractor.make_shareable(value) } # wrapped for UnshareableValue
48
+ Isolated = ->(value, worker:) { make_shareable_copy_or_marshal(value) }
49
+ Shared = ->(value, worker:) { value }
50
+
51
+ TABLE = {frozen: Frozen, isolated: Isolated, shared: Shared}.freeze
52
+ ALIASES = {hardened: :isolated}.freeze
53
+ def self.resolve(name)
54
+ name = canonical(name) # applies ALIASES + :hardened deprecation warn
55
+ TABLE.fetch(name) { raise ArgumentError, "unknown policy #{name.inspect}" }
56
+ end
57
+ end
58
+ end
59
+ ```
60
+
61
+ Not a class hierarchy, not a mixin (policy is a collaborator, not worker state — contrast Taggable). Each apply wraps make_shareable/Marshal failures as UnshareableValue. Precedence inline:
62
+ ```ruby
63
+ def effective_policy
64
+ @effective_policy ||= Policy.resolve(@policy || @pipeline_policy || Shifty.config.default_policy)
65
+ end
66
+ ```
67
+
68
+ **A3: Two attributes — `@policy` (explicit contract) and `@pipeline_policy` (default) — `.with_policy` walks the supply chain; Gang fans out to roster.**
69
+
70
+ ```ruby
71
+ # worker.rb
72
+ attr_accessor :pipeline_policy
73
+ def initialize(p = {}, &block)
74
+ @policy = p[:policy] # explicit contract, may be nil
75
+ @name = p[:name] # §5.5 diagnostics
76
+ end
77
+
78
+ def with_policy(name)
79
+ Policy.resolve(name) # validate eagerly
80
+ node = self
81
+ while node.respond_to?(:pipeline_policy=) # walks Workers and Gangs
82
+ node.pipeline_policy = name
83
+ node = node.supply
84
+ end
85
+ self
86
+ end
87
+ ```
88
+ ```ruby
89
+ # gang.rb
90
+ def initialize(workers = [], p = {})
91
+ self.pipeline_policy = p[:policy] if p[:policy]
92
+ end
93
+ def pipeline_policy=(name)
94
+ roster.workers.each { |w| w.pipeline_policy = name }
95
+ end
96
+ def with_policy(name)
97
+ self.pipeline_policy = name
98
+ self
99
+ end
100
+ ```
101
+ Gang#supply returns roster.first.supply, so the walk steps past a gang to its upstream after the gang fans out internally. Rejected: single @policy + declared? flag (forces branching); downstream-walk storage (workers don't know their consumer).
102
+
103
+ **A4: Consolidate errors into lib/shifty/errors.rb under Shifty::Error base.**
104
+ ```ruby
105
+ module Shifty
106
+ class Error < StandardError; end
107
+ class WorkerError < Error; end # reparented, source-compatible
108
+ class WorkerInitializationError < Error; end # moved from dsl.rb
109
+ class PolicyError < Error; end
110
+ class PolicyViolation < PolicyError; end # attrs: worker, policy, receiver, value, cause (§6.2)
111
+ class UnshareableValue < PolicyError; end # attrs: worker, policy, value
112
+ end
113
+ ```
114
+ (PolicyConflict was proposed as class-only; user decision R2: dropped entirely.)
115
+
116
+ **A5: Global config.**
117
+ ```ruby
118
+ # lib/shifty/configuration.rb
119
+ module Shifty
120
+ class Configuration
121
+ attr_accessor :default_policy
122
+ def initialize; @default_policy = :frozen; end
123
+ end
124
+ class << self
125
+ def config; @config ||= Configuration.new; end
126
+ def configure; yield config; end
127
+ def reset_configuration!; @config = Configuration.new; end # test hook
128
+ end
129
+ end
130
+ ```
131
+ Effective policy memoized at first shift (topology assembled by then); per-handoff re-resolution unnecessary. Thread-safety documentation-only, consistent with worker.rb:117–130.
132
+
133
+ **A6: `shifty/testing` (pure Ruby) and `shifty/rspec` (opt-in) as separate requires; neither loaded by `require "shifty"`.**
134
+ - lib/shifty/testing.rb: `Shifty::Testing.run(worker, inputs:, policy: nil)` — feeds inputs through the framework so declared/effective policy is exercised (§9.2/§9.3); `Shifty::Testing.mutates_input?(worker, input)` — §6.4 detector: deep-copy, run, compare.
135
+ - lib/shifty/rspec.rb: `RSpec.shared_examples "a policy-safe worker"` (ceiling test §9.4) and `mutate_input` matcher built on the detector.
136
+
137
+ **A7: Fix trailing_worker to hand off `trail.dup`; route side_worker :hardened through the deprecation shim (Policy.canonical).** DSL constructors already splat options into Worker.new, so policy:/name: pass through for free; only trailing_worker and source_worker build options by hand and need explicit threading. Rejected: internally declaring trailing_worker `policy: :shared` — masks the aliasing instead of fixing it.
138
+
139
+ **Deferred (YAGNI) per architect:** PolicyConflict validation (now dropped entirely per user); §8.3 shareable? fast path (reopen: benchmarks show copy cost material); Shifty::Value mixin (§11.5 — spec itself says stay unopinionated).
140
+ **New files:** Phase 1: lib/shifty/errors.rb, lib/shifty/policy.rb, lib/shifty/configuration.rb. Phase 2: lib/shifty/testing.rb, lib/shifty/rspec.rb.
141
+
142
+ ## concurrency-analyst (F1–F22, empirical on Ruby 4.0.5 — re-verify on 3.2 CI)
143
+
144
+ - F1/F2: policy is the *receiving* worker's, applied at intake. Exactly four `supply.shift` read sites (worker.rb:64; dsl.rb:55 filter; dsl.rb:86 batch; dsl.rb:122 trailing) — the minimal enforcement set; no value crosses twice, so no double-copy waste under :isolated.
145
+ - F3: do NOT instrument `handoff`/Fiber.yield — `handoff` is a free DSL function with no access to the executing worker; every internal yield surfaces through the consumer's pull anyway.
146
+ - F4: wrap the supply object ONCE per worker (decorator responding to #shift), substituted at worker.rb:64 and as task's 2nd arg — covers all four sites, zero dsl.rb changes.
147
+ - F5: criteria-bypass path automatically covered (intake happens before the branch).
148
+ - F6: Fiber-local storage registry — YAGNI; `self` is available everywhere needed.
149
+ - **F7 (CRITICAL): make_shareable does NOT reject IO or singleton-methoded objects.** Empirical: proc → Ractor::IsolationError; lazy enumerator → Ractor::Error; IO/File → silently frozen (shareable=true); OpenStruct, Data, singleton-methoded Object → silently frozen. §3.3/§10.1's claims that IO raises are wrong; UnshareableValue never fires for IO unless detection is proactive.
150
+ - **F8: make_shareable(io) freezes the LIVE IO in place** — same object, process-wide: a shared logger/$stdout/file handle frozen for every reference in the process. Materially worse than raising. The :frozen/:isolated wrapper must check IO-like values and raise UnshareableValue proactively BEFORE calling make_shareable.
151
+ - **F9: make_shareable(io, copy: true) duplicates the fd then freezes the duplicate** — new fd opened, unusable, never closed: one leaked fd per :isolated handoff of an IO. Same remedy as F8.
152
+ - F10: singleton-methoded values: Marshal raises TypeError ("singleton can't be dumped") but make_shareable(copy: true) succeeds — a value that failed LOUDLY under :hardened silently succeeds under :isolated. Own migration-guide bullet, own spec.
153
+ - F11: §8.2 short-circuit confirmed structurally: make_shareable(copy: true) returns the SAME object for an already-deeply-frozen Data instance (no copy). CPU amortization still needs §8.4 benchmarks.
154
+ - F12: @context/BatchContext never crosses Fiber.yield (batch hands off collection.compact — a new array); stays mutable per §12. Confirmed out of scope.
155
+ - F13: trailing_worker hands off `trail` itself (dsl.rb:125) while continuing to unshift/pop it → next resume raises FrozenError under :frozen. Fix: hand off `trail.dup`. Required Phase-1 shipped-code fix.
156
+ - F14–F16: batch_worker safe (fresh array per batch + hands off .compact's new array); splitter_worker safe (fresh local per call, hands off elements); filter_worker safe (no closure state). Do NOT "fix" defensively — note as confirmed-safe in spec matrix.
157
+ - F17: side_worker mode: :hardened is the only :hardened reference in the codebase; map to policy: :isolated with deprecation warning.
158
+ - F18: §5.4's Worker#task= does not exist (only lazy `@task ||= default_task`); restate #freeze! motivation as supply=/Roster rewiring.
159
+ - F19: #freeze! must force-materialize the lazy default task (invoke ensure_ready_to_work!) on every worker BEFORE freezing, else a default-task worker raises confusing FrozenError at first shift.
160
+ - F20: #freeze! must also freeze Roster's @workers array — otherwise Gang#append/Roster#push still rewire topology (they set supply= on the NEW unfrozen worker and mutate the array).
161
+ - F21: bare `|` chains have no Roster — Worker#freeze! must walk the .supply chain backward to nil to discover members. Two discovery mechanisms (Gang vs Worker); document which uses which.
162
+ - F22: post-freeze supply= raises plain FrozenError, which is already unambiguous — do NOT add a TopologyFrozenError wrapper (YAGNI; reopen on migration feedback showing confusion).
163
+
164
+ ## test-engineer (house style: rspec-given Given/When/Then; raise_error(/regex/) + structured accessor checks; real values, no doubles for policy behavior)
165
+
166
+ Load-bearing policy × shape matrix (equivalence classes, not the 3×10 product):
167
+ 1. :frozen × Array — `<<` raises PolicyViolation; receiver.equal?(value).
168
+ 2. :frozen × Hash-containing-Array — nested mutation raises; receiver reachable-but-not-equal (proves deep traversal, feeds heuristic).
169
+ 3. :frozen × Data (scalar members) — v.with(...) returns new frozen instance; original untouched.
170
+ 4. :frozen × nil — passthrough.
171
+ 5. :isolated × Array — task mutates its copy freely; output includes mutation; upstream original unaffected.
172
+ 6. :isolated × Data-with-mutable-Array-member — copy's member mutable without affecting original.
173
+ 7. :shared × Array — mutation visible to holders of the same reference.
174
+ 8. :shared × IO — passes through untouched, no error.
175
+ Boundary cases: (9) mid-task supply.shift under upstream :frozen raises PolicyViolation like primary intake (filter_worker); (10) splitter under :frozen — EACH yielded part frozen, not just the last (fails against a naive single-site implementation, deliberately); (11) trailing_worker under :frozen default must not raise + previously-returned trail unaffected by later shifts (proves trail.dup fix); (12) side_worker(policy: :isolated) { |v| v << :boo } — original value continues unmutated (mirrors existing :hardened assertions); (13) criteria-bypass value still policy-applied (Medium).
176
+ Error diagnostics: (14) PolicyViolation structured accessors .worker/.policy/.receiver + message content; (15) receiver heuristic three branches — equal / reachable / unrelated-FrozenError (report both objects, no misattribution; §6.2 answers this); (16) UnshareableValue guidance text matches /:shared/; (17) violation.cause is the original FrozenError (cheap, high regression value).
177
+ Shim & failure sets: (18) :hardened → :isolated behavioral equivalence + deprecation warning emitted once per declaration (capture $stderr; no ActiveSupport); (19) table-driven :isolated failure-set spec over IO/Proc/Lazy/singleton — expected outcomes CANNOT be authored from the spec (F7!); requires an implementation spike on the target Ruby first.
178
+ Meta-tests: (20) Testing.run uses worker's effective policy by default (build policy: :isolated worker, no override, assert isolated semantics); (21) explicit policy: override; (22) mutate_input matcher passes/fails against real workers using the real detector; (23) shared example "a policy-safe worker" passes known-good/fails known-bad. Keep behavioral — assert on outputs and raised errors, not on how run() invokes make_shareable.
179
+ Existing-suite impact: (24) dsl_spec.rb:149–164 `proc { |v| v << :boo }` breaks under :frozen → rewrite non-destructively (v + [:boo]) AND add companion spec with explicit policy: :shared preserving old behavior (the migration worked example); (25) dsl_spec.rb:166–174 :hardened context → retarget to :isolated, keep thin shim regression; (26) dsl_spec.rb:317–341 trailing_worker passes once trail.dup fix lands + new aliasing assertion. worker/gang/roster specs confirmed mutation-free — pass unmodified.
180
+ File layout: lib/shifty/testing.rb → spec/shifty/testing_spec.rb; shared-example spec under spec/support/; matcher spec separate. Skipped (with triggers): nil × isolated/shared; String cells (same class as Array); full cartesian; identity-keyed-cache caveat (§11.2, no trigger); shareable? fast path (deferred by decision); PolicyConflict tests (now dropped per user).
181
+
182
+ ## junior-developer (F1–F13)
183
+
184
+ F1: "major version" language needs 0.x translation; F1a :hardened removal horizon undefined (user: 1.0.0); F1b "vNEXT" placeholders → 0.6.0.
185
+ F2: §5.4 assumes Worker#task= which doesn't exist.
186
+ F3: four handoff sites, spec targets only one (resolved by A1/F4 ingress design).
187
+ F4: trailing_worker/side_worker are shipped code broken under :frozen; phase assignment needed (→ Phase 1).
188
+ F5: main shipped-inconsistent between PRs — OK if nothing is released; version bump + CHANGELOG land in the Phase-4 release PR.
189
+ F6: wiki page list/audience/split undefined (user approved 7-page set).
190
+ F7: CI matrix ruby.yml:21 = ['2.6','2.7','3.0'] — zero supported Rubies once floor is 3.2; matrix → ['3.2','3.3','3.4']; actions/checkout@v2 and SHA-pinned setup-ruby stale. Release blocker; Phase 1.
191
+ F8: codeclimate-test-reporter pins simplecov ≤ 0.13 (2016), abandoned — drop while touching gemspec.
192
+ F9: gemspec lacks required_ruby_version; add >= 3.2 together with CI change.
193
+ F10: benchmark suite: no home/run-mode/dep decided; §8.2 amortization is load-bearing-but-unvalidated for the default; benchmarks land Phase 3 — if they contradict §8.2, revisit default before release.
194
+ F11: stray .gem files in root/pkg (untracked, gitignored); Gemfile.lock gitignored (conventional for gems); release-phase sweep.
195
+ F12: mutation detector + matcher + shared example sized; keep detector (powers migration tooling); both RSpec constructs are thin wrappers (kept per spec/user commitment).
196
+ F13: name: vs tags (kept per §5.5); PolicyConflict vacuous (user: dropped); #freeze! justification corrected to supply=/Roster (kept, Phase 3).
@@ -0,0 +1,231 @@
1
+ # Implementation Decision Log: Handoff Immutability Policies
2
+
3
+ <!--
4
+ This file records every implementation decision committed while planning Handoff Immutability Policies.
5
+ Behavioral and implementation statements live in [../feature-implementation-plan.md](../feature-implementation-plan.md) —
6
+ this file captures the question, rationale, evidence, and rejected alternatives for each decision.
7
+ Round-by-round history lives in [implementation-iteration-history.md](implementation-iteration-history.md).
8
+ Source spec: [../handoff-immutability-policies.md](../handoff-immutability-policies.md).
9
+ Verbatim specialist outputs (referenced by A#/F#/test-# below): [.round1-specialist-outputs.md](.round1-specialist-outputs.md).
10
+
11
+ The D-N counter is shared across the trivial and full sections.
12
+ -->
13
+
14
+ ## Trivial decisions
15
+
16
+ - D-14: `name:` kwarg retained (§5.5) — workers gain an optional `name:` for diagnostics alongside existing tags; kept per spec commitment and Joel's full-scope decision, no alternative worth debating. — Referenced in plan: Implementation Approach (Policy module and diagnostics).
17
+ - D-15: Seven-page wiki set — Home, Handoff-Policies, Coding-Idioms-Under-Frozen, Migration-Guide-0.6, Testing-Workers, Worker-Types, Performance; drafted under `docs/wiki/`, pushed to the GitHub wiki repo after merge; README slims to a quick tour plus links (Joel, R2). — Referenced in plan: Decomposition and Sequencing (PR 4).
18
+ - D-16: Drop `codeclimate-test-reporter` — the dev dep pins simplecov ≤ 0.13 (2016-era) and is effectively abandoned; removed while the gemspec is already open in PR 1 (junior-developer F8/C20). — Referenced in plan: Decomposition and Sequencing (PR 1).
19
+ - D-17: Real values, no doubles for policy behavior — policy specs assert on real frozen/copied values and raised errors, not on how the framework invokes `make_shareable`; matches the repo's rspec-given house style (test-engineer). — Referenced in plan: Testing Strategy.
20
+ - D-18: `shifty/testing` and `shifty/rspec` as separate opt-in requires — neither is loaded by `require "shifty"`; pure-Ruby harness split from the RSpec sugar (software-architect A6/C13). — Referenced in plan: Implementation Approach (testing layering), Decomposition and Sequencing (PR 2).
21
+ - D-19: Global configuration object — `Shifty::Configuration` with `default_policy` (built-in `:frozen`) plus `Shifty.configure`/`config`/`reset_configuration!`; API shape committed by Joel, effective policy memoized at first shift (software-architect A5/C12). — Referenced in plan: Implementation Approach (config), Decomposition and Sequencing (PR 1).
22
+ - D-20: `side_worker` accepts both `mode:` and `policy:` spellings — the lone `mode: :hardened` reference (dsl.rb:35) routes through `Policy.canonical` to `:isolated` with a one-time deprecation warning; both spellings pass through DSL option-splatting (software-architect A7, concurrency F17/C8). — Referenced in plan: Implementation Approach, Decomposition and Sequencing (PR 1).
23
+ - D-21: Mutation detector plus both RSpec constructs ship in PR 2 — the detector powers migration tooling, and the `mutate_input` matcher and `"a policy-safe worker"` shared example are both thin wrappers over it; kept per spec §9.3 and Joel's full-scope decision (junior-developer F12/C23 Disputed→resolved). — Referenced in plan: Implementation Approach (testing layering), Testing Strategy, Decomposition and Sequencing (PR 2).
24
+ - D-22: Release-phase repo sweep — remove stray `.gem` artifacts from repo root / `pkg`; confirm `Gemfile.lock` stays gitignored (conventional for a gem); housekeeping folded into PR 4 (junior-developer F11/C21). — Referenced in plan: Decomposition and Sequencing (PR 4).
25
+
26
+ ## Full decisions
27
+
28
+ ### D-1: Ingress-seam enforcement via PolicySupply decorator
29
+
30
+ - **Question:** Where in the worker runtime is a handoff policy applied so that every value crossing a worker boundary is governed exactly once?
31
+ - **Decision:** Apply policy at the **consuming worker's intake** (its supply pull), not at the producer's egress. Introduce a single `intake(value)` seam wrapping `worker.rb:64` (`value = supply&.shift`) and a thin `PolicySupply` decorator (responding to `#shift` as `intake(supply.shift)`) substituted as the task's 2nd argument at `worker.rb:66`. This covers all four `supply.shift` read sites (worker.rb:64; dsl.rb:55 filter, dsl.rb:86 batch, dsl.rb:122 trailing) with zero dsl.rb task-body changes. Producer-side `handoff`/`Fiber.yield` sites (source_worker, splitter_worker) get no instrumentation.
32
+ - **Rationale:** §6.2 names the *receiving* worker in the error, so policy is a property of how a worker receives its value. Every value a producer yields surfaces through some consumer's pull, so the consumer intake is the complete and minimal enforcement set; no value crosses twice, so `:isolated` never double-copies. Wrapping the supply once per worker satisfies the Rule of Three (three in-task `supply.shift` uses exist today) without touching task bodies. §7.3 aliasing is still caught: `make_shareable` freezes the actual object in place, so a producer that keeps mutating a handed-off object raises at the producer.
33
+ - **Evidence:** Independent convergence of software-architect A1 and concurrency-analyst F1–F4 (see [.round1-specialist-outputs.md](.round1-specialist-outputs.md)); enforcement sites enumerated at worker.rb:64/66, dsl.rb:55/86/122; discovery notes on the four `supply.shift` sites and producer-side `handoff`. C1–C3.
34
+ - **Rejected alternatives:**
35
+ - Freeze at the single egress site (`Fiber.yield @task.call(...)`, worker.rb:66) — rejected because it misses in-task `supply.shift` in filter/batch/trailing workers and makes the *producer's* policy govern the boundary, contradicting §5.2 (worker declarations are the receiver's contract). Evidence: A1, F1.
36
+ - Instrument `handoff`/`Fiber.yield` producer sites — rejected because `handoff` is a free DSL function with no access to the executing worker, and every internal yield already surfaces at the consumer's pull (double coverage, no gain). Evidence: F3.
37
+ - **Specialist owner:** software-architect (design), concurrency-analyst (handoff-site correctness).
38
+ - **Revisit criterion:** A future worker type that consumes values without a `supply.shift` intake (e.g. a Ractor-backed worker, §11.6) would need a new enforcement seam.
39
+ - **Dissent (if any):** None — specialists converged.
40
+ - **Driven by rounds:** R1.
41
+ - **Dependent decisions:** D-2, D-3, D-4, D-7, D-12, D-13.
42
+ - **Referenced in plan:** Implementation Approach (Architecture and Integration Points; Runtime Behavior), Decomposition and Sequencing (PR 1).
43
+
44
+ ### D-2: Proactive IO detection for UnshareableValue
45
+
46
+ - **Question:** How is `UnshareableValue` (§6.2) raised for IO handles, sockets, and similar values, given the spec's stated assumption that `make_shareable` rejects them?
47
+ - **Decision:** Detect IO-like values **proactively** in the `:frozen` and `:isolated` apply paths and raise `Shifty::UnshareableValue` *before* calling `Ractor.make_shareable`. Do not rely on `make_shareable` raising. Proc and `Enumerator::Lazy` continue to raise naturally and are wrapped as `UnshareableValue`.
48
+ - **Rationale:** Empirical testing refutes spec §3.3/§10.1: `make_shareable` does **not** reject IO or singleton-methoded objects. It silently freezes a live IO in place — a process-wide side effect freezing a shared `$stdout`/logger/file handle for every reference in the process (materially worse than raising), and `copy: true` on an IO duplicates the fd, freezes the copy, and leaks one unusable fd per handoff. The spec's *intent* (UnshareableValue for uncopyable values) is preserved only if detection is proactive.
49
+ - **Evidence:** concurrency-analyst F7 (CRITICAL), F8, F9, F10 — empirical on Ruby 4.0.5; C4, C5. Re-verification on the Ruby 3.2 CI floor is a PR-1 spike task (see D-8).
50
+ - **Rejected alternatives:**
51
+ - Trust the spec and let `make_shareable` reject IO — rejected: empirically false; it silently freezes live IO process-wide (F8) and leaks fds under `copy: true` (F9).
52
+ - Reactive-only wrapping (catch whatever `make_shareable` raises) — rejected: for IO nothing is raised, so the damage is done silently before any wrap could fire (F7/F8).
53
+ - **Specialist owner:** concurrency-analyst.
54
+ - **Revisit criterion:** If the PR-1 spike on Ruby 3.2 finds `make_shareable` behavior differs materially from the 4.0.5 observations, the detection predicate and the `:isolated` failure-set table (test #19) are re-derived from the spike results.
55
+ - **Dissent (if any):** None.
56
+ - **Driven by rounds:** R1.
57
+ - **Dependent decisions:** D-5, D-8, D-13.
58
+ - **Referenced in plan:** Implementation Approach (Runtime Behavior; empirical spike), RAID Log (assumptions/risks), Testing Strategy.
59
+
60
+ ### D-3: Policy as three frozen singletons
61
+
62
+ - **Question:** What structure implements the three policies and their name resolution (including the `:hardened` alias)?
63
+ - **Decision:** A `Shifty::Policy` module holding three frozen singleton lambdas (`Frozen`, `Isolated`, `Shared`) behind `Policy.resolve(name)`, with a `TABLE` and an `ALIASES = {hardened: :isolated}` map; `canonical(name)` applies aliases and the `:hardened` deprecation warning. Not a class hierarchy, not a mixin. Each apply wraps `make_shareable`/Marshal failures (and the proactive IO check from D-2) as `UnshareableValue`.
64
+ - **Rationale:** Policy is a collaborator, not worker state (contrast the `Taggable` mixin), so a module of stateless strategies is the smallest fit. `resolve`/`ALIASES` gives one place for validation and the deprecation shim. Three concrete policies exist today — no speculative extensibility.
65
+ - **Evidence:** software-architect A2; C9. Existing `Taggable` mixin precedent in `lib/shifty/taggable.rb` (discovery notes).
66
+ - **Rejected alternatives:**
67
+ - Class hierarchy of policy objects — rejected: no per-instance state to hold; adds ceremony over three stateless lambdas (A2).
68
+ - Mixin on Worker (Taggable-style) — rejected: policy is a collaborator applied to values, not worker identity/state (A2).
69
+ - **Specialist owner:** software-architect.
70
+ - **Revisit criterion:** A fourth policy, or per-policy configuration state, would justice a richer object model.
71
+ - **Dissent (if any):** None.
72
+ - **Driven by rounds:** R1.
73
+ - **Dependent decisions:** D-4, D-9, D-20.
74
+ - **Referenced in plan:** Implementation Approach (Policy module), Decomposition and Sequencing (PR 1).
75
+
76
+ ### D-4: Two-attribute policy precedence
77
+
78
+ - **Question:** How is the worker > pipeline > global precedence (§5.2) represented and how does `.with_policy` propagate?
79
+ - **Decision:** Two nullable attributes on Worker — `@policy` (explicit contract) and `@pipeline_policy` (default) — resolved by `Policy.resolve(@policy || @pipeline_policy || Shifty.config.default_policy)`, memoized in `effective_policy`. `.with_policy(name)` validates eagerly, then walks the `.supply` chain upstream setting `pipeline_policy=` on each node; Gang fans `pipeline_policy=` out across its roster and `Gang#supply` returns `roster.first.supply` so the walk steps past a gang.
80
+ - **Rationale:** Two attributes let a nil `@policy` mean "undeclared" without a separate `declared?` flag or branching. There is no chain object for bare `|` chains — a pipeline is just the last worker (discovery notes) — so `.with_policy` must live on Worker and Gang and propagate via the supply chain. Workers do not know their consumer, so propagation walks upstream, not down.
81
+ - **Evidence:** software-architect A3; C9. Discovery notes: composition links workers by setting `supply`; no pipeline object exists; Gang is the only reifying container.
82
+ - **Rejected alternatives:**
83
+ - Single `@policy` plus a `declared?` flag — rejected: forces branching everywhere the flag is read (A3).
84
+ - Store policy by walking downstream to the consumer — rejected: workers hold no reference to their consumer, only to their supply (A3).
85
+ - **Specialist owner:** software-architect.
86
+ - **Revisit criterion:** A reified pipeline/chain object (if introduced later) would relocate `.with_policy` off Worker.
87
+ - **Dissent (if any):** None.
88
+ - **Driven by rounds:** R1.
89
+ - **Dependent decisions:** D-19.
90
+ - **Referenced in plan:** Implementation Approach (precedence), Decomposition and Sequencing (PR 1).
91
+
92
+ ### D-5: Consolidated error hierarchy
93
+
94
+ - **Question:** Where do the new and existing error classes live, and under what base?
95
+ - **Decision:** Consolidate errors into `lib/shifty/errors.rb` under a `Shifty::Error` base, with a `PolicyError` intermediate: `PolicyViolation < PolicyError` (attrs: worker, policy, receiver, value, cause) and `UnshareableValue < PolicyError` (attrs: worker, policy, value). Reparent existing `WorkerError`/`WorkerInitializationError` under `Shifty::Error` (source-compatible) and move `WorkerInitializationError` out of dsl.rb. No `PolicyConflict` class (see D-6).
96
+ - **Rationale:** A single error file with a common base is idiomatic and lets callers rescue `Shifty::Error` broadly or `PolicyError` narrowly. Reparenting is source-compatible because the existing classes keep their names.
97
+ - **Evidence:** software-architect A4; C10. `PolicyViolation`/`UnshareableValue` attribute lists from spec §6.2.
98
+ - **Rejected alternatives:**
99
+ - Leave errors scattered across dsl.rb and worker.rb — rejected: no common ancestor for `rescue`, and the new policy errors need a home anyway (A4).
100
+ - Include a `PolicyConflict` class — rejected: nothing to raise it (see D-6).
101
+ - **Specialist owner:** software-architect.
102
+ - **Revisit criterion:** A new error family (e.g. topology errors) would extend, not revise, this hierarchy.
103
+ - **Dissent (if any):** None.
104
+ - **Driven by rounds:** R1.
105
+ - **Dependent decisions:** D-2, D-6.
106
+ - **Referenced in plan:** Implementation Approach (errors), Decomposition and Sequencing (PR 1).
107
+
108
+ ### D-6: PolicyConflict dropped entirely
109
+
110
+ - **Question:** Does the plan ship a `PolicyConflict` class and build-time validation (spec §5.3, Phase 2)?
111
+ - **Decision:** Drop `PolicyConflict` entirely — no class, no validation.
112
+ - **Rationale:** The committed precedence rule (worker declarations authoritative, pipeline policy default-only, §11.4) leaves no violable rule to enforce, so there is nothing to raise. Joel (R2): "Since we're defaulting to frozen and allowing workers to opt out to something more permissive, we don't have a PolicyConflict... nothing to raise means no class, and we'll revisit later if we need to refine this."
113
+ - **Evidence:** User input, R2 (verbatim above). software-architect A4 and junior-developer F13/C11 independently flagged the class as vacuous.
114
+ - **Rejected alternatives:**
115
+ - Define the class now, defer only the detection logic — rejected: a class with no raiser is dead code future agents would copy; Joel chose to drop it outright (R2).
116
+ - Implement full build-time validation now — rejected: no rule exists to validate under the committed precedence semantics (A4, C11).
117
+ - **Specialist owner:** software-architect.
118
+ - **Revisit criterion:** A strict-Gang feature (§11.4) that lets a pipeline *forbid* worker-level loosening — that would introduce a violable rule and reopen the need for both the class and detection.
119
+ - **Dissent (if any):** None.
120
+ - **Driven by rounds:** R1 (flagged), R2 (decided).
121
+ - **Dependent decisions:** —.
122
+ - **Referenced in plan:** Implementation Approach (errors), Decomposition and Sequencing (PR 2 scope note), Deferred (YAGNI).
123
+
124
+ ### D-7: trailing_worker aliasing fix in Phase 1
125
+
126
+ - **Question:** `trailing_worker` (dsl.rb:112–130) hands off its live closure `trail` array while continuing to `unshift`/`pop` it — under the `:frozen` default the next resume raises `FrozenError`. Where and how is this fixed?
127
+ - **Decision:** Fix the shipped worker in **PR 1** to hand off `trail.dup` (a snapshot), not the live array. batch/splitter/filter workers are confirmed safe as written and are **not** defensively changed.
128
+ - **Rationale:** This is exactly the aliasing bug class the policy exists to catch (§7.3), but it is in Shifty's own shipped DSL, so it must be fixed rather than merely documented. It travels with the mechanism (PR 1) because the `:frozen` default lands there. batch (fresh array + `.compact` new array), splitter (fresh local per call), and filter (no closure state) do not alias, so touching them would add risk for no benefit.
129
+ - **Evidence:** concurrency-analyst F13–F16 (trailing aliases at dsl.rb:125; others safe); junior-developer F4; test-engineer #11/#26; C7.
130
+ - **Rejected alternatives:**
131
+ - Internally declare `trailing_worker policy: :shared` — rejected: masks the aliasing instead of fixing it, and denies trailing_worker users the `:frozen` guarantee (software-architect A7).
132
+ - Leave it and document as a known break — rejected: it is first-party code; shipping a framework whose own DSL breaks under its own default is not acceptable.
133
+ - **Specialist owner:** concurrency-analyst.
134
+ - **Revisit criterion:** None expected; regression is guarded by test #26.
135
+ - **Dissent (if any):** None.
136
+ - **Driven by rounds:** R1.
137
+ - **Dependent decisions:** D-12, D-13.
138
+ - **Referenced in plan:** Implementation Approach (Runtime Behavior), Decomposition and Sequencing (PR 1), Testing Strategy.
139
+
140
+ ### D-8: Ruby 3.2 floor and CI matrix in Phase 1
141
+
142
+ - **Question:** Which PR owns `required_ruby_version >= 3.2` and the CI matrix rewrite?
143
+ - **Decision:** Land `required_ruby_version >= 3.2` in the gemspec and rewrite the CI matrix from `['2.6','2.7','3.0']` to `['3.2','3.3','3.4']` (plus refreshing stale `actions/checkout@v2` and the SHA-pinned setup-ruby) in **PR 1**, as a prerequisite to trusting any policy spec. The PR-1 spike re-verifies the D-2 `make_shareable` observations on the 3.2 floor.
144
+ - **Rationale:** The current matrix tests only Rubies *below* the new floor, so without this change every policy spec runs on unsupported, wrong-behavior Rubies. `Data` idioms (§7.2) and mature `make_shareable` need ≥ 3.2. This is a release blocker that gates the correctness of everything else in PR 1.
145
+ - **Evidence:** junior-developer F7, F9; C15; resolved by evidence/aggregation (OQ-4). CI matrix at `.github/workflows/ruby.yml:21`; gemspec lacks `required_ruby_version` (discovery notes).
146
+ - **Rejected alternatives:**
147
+ - Put CI/gemspec changes in the Phase-4 release PR — rejected: policy specs in PRs 1–3 would run on unsupported Rubies with different `make_shareable`/Data behavior, invalidating them (F7, OQ-4).
148
+ - A separate prerequisite PR before PR 1 — rejected: adds a fifth PR for a change that naturally belongs with the mechanism it enables; one-PR-per-phase is the committed shape.
149
+ - **Specialist owner:** junior-developer (raised); PM (sequencing).
150
+ - **Revisit criterion:** None; floor is fixed for the 0.6.0 line.
151
+ - **Dissent (if any):** None.
152
+ - **Driven by rounds:** R1.
153
+ - **Dependent decisions:** D-2.
154
+ - **Referenced in plan:** Decomposition and Sequencing (PR 1), RAID Log.
155
+
156
+ ### D-9: :hardened deprecation horizon
157
+
158
+ - **Question:** In 0.x terms, when is the deprecated `:hardened` option removed?
159
+ - **Decision:** `:hardened` is **deprecated in 0.6.0** (mapped to `:isolated` with a one-time warning via `Policy.canonical`) and **removed at 1.0.0**. Docs state "deprecated in 0.6.0, removed in 1.0.0."
160
+ - **Rationale:** The spec speaks of "one major version, then removed," which is undefined in a pre-1.0 line. Joel (R2) set the concrete horizon: keep the shim through the 0.x series and drop it at the 1.0.0 boundary, which is the natural major-version break.
161
+ - **Evidence:** User input, R2. junior-developer F1/C16 flagged the undefined 0.x horizon (OQ-1).
162
+ - **Rejected alternatives:**
163
+ - Remove at the next minor (0.7.0) — rejected: too aggressive for a deprecation users have not yet seen; Joel chose the 1.0.0 boundary (R2).
164
+ - Keep indefinitely — rejected: leaves a dead alias and the Marshal-divergence caveat (C5) live forever (R2).
165
+ - **Specialist owner:** software-architect (shim), PM (horizon).
166
+ - **Revisit criterion:** If 1.0.0 slips materially, re-confirm the removal still rides that release.
167
+ - **Dissent (if any):** None.
168
+ - **Driven by rounds:** R1 (flagged), R2 (decided).
169
+ - **Dependent decisions:** D-20.
170
+ - **Referenced in plan:** Implementation Approach, Decomposition and Sequencing (PR 1), Deferred/Migration notes.
171
+
172
+ ### D-10: Manual-run benchmark directory
173
+
174
+ - **Question:** Where do the §8.4 benchmarks live, how are they run, and what dependency do they add?
175
+ - **Decision:** A `benchmark/` directory with `benchmark-ips` as a **dev** dependency, **manual-run** (not in CI), results published to the wiki Performance page. Benchmarks land in **PR 3**. If they contradict the §8.2 amortization claim, the `:frozen` default is revisited before release.
176
+ - **Rationale:** The §8.2 amortization claim is load-bearing for choosing `:frozen` as default but is uncited until measured. Running benchmarks in CI would make the suite slow and flaky for a measurement that is consulted occasionally; manual runs with published results fit a gem with no perf SLO.
177
+ - **Evidence:** junior-developer F10/C19/C6; resolved by aggregation (OQ-6). No benchmark tooling exists today (discovery notes).
178
+ - **Rejected alternatives:**
179
+ - CI-run benchmarks — rejected: slow/flaky CI for an occasionally-consulted measurement; replaced by manual runs + published results (OQ-6).
180
+ - Skip benchmarks — rejected: the default-policy choice rests on the unvalidated §8.2 claim (F10); measurement is required before release.
181
+ - **Specialist owner:** junior-developer (raised); PM (sequencing).
182
+ - **Revisit criterion:** Benchmarks show `:frozen` amortization does not hold on representative value shapes → revisit the default before 0.6.0 ships (RAID R1).
183
+ - **Dissent (if any):** None.
184
+ - **Driven by rounds:** R1.
185
+ - **Dependent decisions:** —.
186
+ - **Referenced in plan:** Decomposition and Sequencing (PR 3), RAID Log, Deferred (YAGNI) (CI-run benchmarks).
187
+
188
+ ### D-11: Version bump and CHANGELOG in Phase-4 PR
189
+
190
+ - **Question:** When do the version bump to 0.6.0 and the CHANGELOG entry land, given one PR per phase and shipped-but-unreleased `main` between PRs?
191
+ - **Decision:** The version bump (`lib/shifty/version.rb` → 0.6.0) and the CHANGELOG entry land in the **Phase-4 (release) PR**, together with the README rewrite, wiki drafts, PR-26 doc reconciliation, and the gem build. `main` may carry unreleased behavior between PRs 1–3.
192
+ - **Rationale:** Nothing is published until Joel builds and pushes 0.6.0, so `main` being shipped-but-not-yet-released between phase PRs is harmless; deferring the version/CHANGELOG to the release PR keeps a single coherent release note rather than four partial bumps. This is a process-convention call, not an evidenced one.
193
+ - **Evidence:** junior-developer F5/C17 (Anecdotal — process convention); reframed the release-timing question, resolved by convention (OQ-5).
194
+ - **Rejected alternatives:**
195
+ - Bump version per-phase PR — rejected: four intermediate versions for one logical release; no publish happens between them so the bumps carry no signal (F5).
196
+ - **Specialist owner:** junior-developer (reframed); Joel (publishes).
197
+ - **Revisit criterion:** If any phase PR is released independently, that PR must carry its own bump/CHANGELOG.
198
+ - **Dissent (if any):** None.
199
+ - **Driven by rounds:** R1.
200
+ - **Dependent decisions:** —.
201
+ - **Referenced in plan:** Decomposition and Sequencing (PR 4), Definition of Done.
202
+
203
+ ### D-12: Terminal output governance out of scope
204
+
205
+ - **Question:** The value the last worker's `shift` returns to user code crosses no consumer intake and is therefore un-governed by any policy. Is that a gap to close?
206
+ - **Decision:** Leave terminal output un-governed; document it in the wiki/migration guide. No framework mechanism wraps the final `shift`.
207
+ - **Rationale:** Spec §2 scopes policies to *worker boundaries*; the terminal caller is not a worker, so there is no boundary to govern. The one shipped aliasing hazard at the terminal (trailing_worker) is removed by D-7, so the residual is documentation only.
208
+ - **Evidence:** aggregator synthesis from A1/F1; C25; resolved by evidence against spec §2 scoping.
209
+ - **Rejected alternatives:**
210
+ - Govern terminal output by wrapping the final `shift` — rejected: the terminal caller is outside the worker-boundary model the policies are scoped to (§2); adding a special case there would be scope creep with no boundary to protect (C25).
211
+ - **Specialist owner:** software-architect.
212
+ - **Revisit criterion:** If a use case emerges where user code downstream of the last worker needs the same mutation guarantees, reconsider a terminal policy.
213
+ - **Dissent (if any):** None.
214
+ - **Driven by rounds:** R1.
215
+ - **Dependent decisions:** —.
216
+ - **Referenced in plan:** Implementation Approach (Runtime Behavior), Decomposition and Sequencing (PR 4, wiki).
217
+
218
+ ### D-13: Equivalence-class test matrix
219
+
220
+ - **Question:** How much of the policy × value-shape space does the test suite cover?
221
+ - **Decision:** Cover **8 load-bearing equivalence-class cells** (not the full 3×10 cartesian), plus 5 boundary-case behaviors (#9–13), 4 error-diagnostic contracts (#14–17), the deprecation shim + a table-driven `:isolated` failure-set spec (#18–19), 4 meta-tests for the testing harness (#20–23), and 3 existing-spec migrations (#24–26). The `:isolated` failure-set table (#19) cannot be authored from the spec and requires an implementation spike on the target Ruby first (per D-2). Real values throughout, no doubles.
222
+ - **Rationale:** The full cartesian is mostly redundant — String behaves as Array, nil × isolated/shared is trivial — so equivalence classes give the same confidence at a fraction of the maintenance. The failure-set table must be spiked because the spec's rejection claims are empirically wrong (D-2).
223
+ - **Evidence:** test-engineer full prioritized plan; C22. Existing specs that break under `:frozen`: dsl_spec.rb:149–164, 166–174, 317–341.
224
+ - **Rejected alternatives:**
225
+ - Full 3×10 policy × value-shape cartesian — rejected: String duplicates Array, nil cells are trivial passthrough; equivalence classes cover the load-bearing behavior with far less to maintain (test-engineer).
226
+ - **Specialist owner:** test-engineer.
227
+ - **Revisit criterion:** A new value shape with distinct freeze/copy behavior (outside the existing equivalence classes) adds a cell.
228
+ - **Dissent (if any):** None.
229
+ - **Driven by rounds:** R1.
230
+ - **Dependent decisions:** —.
231
+ - **Referenced in plan:** Testing Strategy, Decomposition and Sequencing (all PRs).
@@ -0,0 +1,73 @@
1
+ # Implementation Iteration History: Handoff Immutability Policies
2
+
3
+ <!--
4
+ This file records how the implementation plan evolved across discussion rounds.
5
+ Committed decisions live in [implementation-decision-log.md](implementation-decision-log.md)
6
+ and the primary plan lives in [../feature-implementation-plan.md](../feature-implementation-plan.md).
7
+ Source spec: ../handoff-immutability-policies.md (design doc; no plan-a-feature artifacts,
8
+ so no T# technical notes exist and the T#-contradiction classification does not apply).
9
+ -->
10
+
11
+ ## R1: Parallel specialist review
12
+
13
+ - **Specialists engaged:** concurrency-analyst, software-architect, test-engineer, junior-developer (project-manager reserved for synthesis; deterministic aggregation used per skill).
14
+ - **New input provided:** Initial feature spec (`../handoff-immutability-policies.md`), discovery notes (`.discovery-notes.md`), and Joel's nine committed decisions (0.6.0, all four phases, Ruby ≥ 3.2 floor, API shape, one PR per phase, wiki via docs/wiki/, :hardened→:isolated, no shareable? fast path, Joel publishes gem).
15
+
16
+ - **Claim ledger:**
17
+
18
+ | # | Claim | Raised by | Category | State |
19
+ |---|-------|-----------|----------|-------|
20
+ | C1 | Policy must be enforced at the consumer's intake (supply pull), via a supply-wrapping decorator substituted at worker.rb:64 and as the task's 2nd arg at worker.rb:66; covers all four `supply.shift` sites (worker.rb:64, dsl.rb:55, dsl.rb:86, dsl.rb:122) with zero dsl.rb task-body changes | concurrency-analyst (F1–F4), software-architect (A1) — independent convergence | design | Evidenced |
21
+ | C2 | Producer-side `handoff`/`Fiber.yield` sites (source_worker, splitter_worker) need no instrumentation — every yielded value surfaces through the consumer's pull | concurrency-analyst (F3), software-architect (A1) | design | Evidenced |
22
+ | C3 | criteria-bypass path (worker.rb:68) is automatically covered by intake-side enforcement | concurrency-analyst (F5) | edge-case | Evidenced |
23
+ | C4 | **Spec §3.3/§10.1 are factually wrong**: `make_shareable` does NOT reject IO or singleton-methoded objects — it silently freezes live IO in place (process-wide side effect) and `copy: true` on IO leaks a file descriptor. Only Proc and Enumerator::Lazy raise. UnshareableValue must be raised proactively for IO-like values | concurrency-analyst (F7–F9), empirical on Ruby 4.0.5 | assumption-refuted | Evidenced (re-verify on 3.2 CI) |
24
+ | C5 | Marshal-vs-make_shareable divergence includes a silent-behavior-change case: singleton-methoded values failed loudly under :hardened (Marshal) but silently succeed under :isolated (make_shareable) — migration guide must call this out separately | concurrency-analyst (F10) | edge-case | Evidenced |
25
+ | C6 | §8.2 already-shareable short-circuit confirmed structurally (copy: true returns same object for frozen Data); CPU amortization claim still needs §8.4 benchmarks | concurrency-analyst (F11), junior-developer (F10) | ambiguity | Evidenced (partial) |
26
+ | C7 | trailing_worker (dsl.rb:112–130) hands off its live closure array and breaks under :frozen — required Phase-1 shipped-code fix (`trail.dup`); batch/splitter/filter workers confirmed safe as written | concurrency-analyst (F13–F16), junior-developer (F4), test-engineer (#11) | edge-case | Evidenced |
27
+ | C8 | side_worker `mode: :hardened` (dsl.rb:35,40–41) maps to policy: :isolated with deprecation warning via Policy.canonical; shim must accept both `mode:` and `policy:` spellings | concurrency-analyst (F17), software-architect (A7), test-engineer (#18) | design | Evidenced |
28
+ | C9 | Policy strategies as three frozen singletons in `Shifty::Policy` with `resolve(name)` + ALIASES; not a class hierarchy, not a mixin; precedence as two nullable attributes (@policy contract, @pipeline_policy default) resolved by `||`-chain; `.with_policy` walks the supply chain upstream, Gang fans out to roster | software-architect (A2, A3) | design | Evidenced |
29
+ | C10 | Errors consolidate into lib/shifty/errors.rb under Shifty::Error base with PolicyError intermediate; existing WorkerError/WorkerInitializationError reparented (source-compatible) | software-architect (A4) | design | Evidenced |
30
+ | C11 | `PolicyConflict` build-time validation has nothing to enforce: committed decision (worker wins, pipeline default-only, §11.4) leaves no violable rule. Define the class, defer the detection | software-architect (A4), junior-developer (F13) | YAGNI-candidate | Evidenced |
31
+ | C12 | Global config: `Shifty::Configuration` object + `Shifty.configure`/`config`/`reset_configuration!`; effective policy memoized at first shift | software-architect (A5) | design | Evidenced |
32
+ | C13 | shifty/testing (pure Ruby) and shifty/rspec (opt-in) as separate requires, neither loaded by `require "shifty"` | software-architect (A6) | design | Evidenced |
33
+ | C14 | §5.4 cites a `Worker#task=` writer that does not exist; #freeze! motivation must be restated (supply= + Roster rewiring). #freeze! must (a) force-materialize lazy @task first, (b) freeze Roster @workers array, (c) walk .supply chain for bare `\|` chains | concurrency-analyst (F18–F21), junior-developer (F2) | assumption-refuted + design | Evidenced |
34
+ | C15 | CI matrix (.github/workflows/ruby.yml:21) tests only 2.6/2.7/3.0 — zero supported Rubies under the 3.2 floor; matrix rewrite + required_ruby_version are a Phase-1 prerequisite, plus stale actions | junior-developer (F7, F9) | assumption-refuted | Evidenced |
35
+ | C16 | "major version" language throughout spec needs 0.x translation; :hardened removal horizon ("one major version, then removed") is undefined in 0.x terms | junior-developer (F1) | spec-level | Evidenced |
36
+ | C17 | main will be shipped-but-undocumented-inconsistent between phase PRs; version bump + CHANGELOG should land in the Phase-4 (release) PR | junior-developer (F5) | ambiguity | Anecdotal (process convention) |
37
+ | C18 | Wiki page list, audience, and README/wiki split undefined; docs/wiki/ doesn't exist; information-architect input recommended at Phase 4 | junior-developer (F6) | spec-level | Evidenced |
38
+ | C19 | Benchmark suite has no home/run-mode/dependency decision; §8.2 amortization is load-bearing-but-uncited for the :frozen default | junior-developer (F10) | ambiguity | Evidenced |
39
+ | C20 | codeclimate-test-reporter dev dep pins simplecov ≤ 0.13 (2016-era), effectively abandoned — drop while touching gemspec | junior-developer (F8) | edge-case | Evidenced |
40
+ | C21 | Stray .gem files in repo root / pkg; Gemfile.lock gitignored (conventional for a gem) — release-phase sweep item | junior-developer (F11) | polish | Evidenced |
41
+ | C22 | Test plan: 8 load-bearing policy×shape equivalence-class cells (not full cartesian), 4 boundary-case behaviors, 4 error-diagnostic contracts, deprecation shim + empirical failure-set table (spike required before authoring), meta-tests for Testing.run/matcher/shared example, 3 existing specs break under :frozen default (dsl_spec.rb:151, 166–174, 317–341) and double as migration worked examples; real values throughout, no doubles | test-engineer (full plan) | test-coverage | Evidenced |
42
+ | C23 | Mutation detector + both RSpec constructs sized as large Phase-2 surface; matcher and shared example both ship vs one | junior-developer (F12) vs spec §9.3 commitment | YAGNI-candidate | Disputed (resolved: spec + user committed all; both are thin wrappers over the detector) |
43
+ | C24 | name: kwarg (§5.5) vs existing tags for diagnostics | junior-developer (F13) | YAGNI-candidate | Resolved by spec commitment (§5.5, user decision: full scope) |
44
+ | C25 | Terminal-output governance: under intake-side enforcement, the value returned by the LAST worker's `shift` to user code crosses no consumer intake and is un-governed | aggregator (from A1/F1 synthesis) | edge-case | Resolved by evidence: spec §2.1 scopes policies to worker boundaries; terminal caller is not a worker. Document in wiki/migration guide; trailing_worker fix (C7) removes the one shipped aliasing hazard |
45
+
46
+ - **Open Questions raised:**
47
+ - OQ-1 (from C16): What is the :hardened removal horizon in 0.x terms? → escalate to user with recommendation "removed at 1.0.0".
48
+ - OQ-2 (from C11): Defer PolicyConflict detection logic (class only) — deviation from spec §13 Phase 2 scope? → escalate to user with recommendation "defer with reopening trigger".
49
+ - OQ-3 (from C18): Wiki page list and README/wiki split → propose page list to user.
50
+ - OQ-4 (from C15): Which phase owns CI matrix + required_ruby_version? → resolved by evidence/aggregation: Phase 1 PR (prerequisite for trusting policy specs on supported Ruby).
51
+ - OQ-5 (from C17): Version bump + CHANGELOG timing → resolved by convention: Phase-4 release PR; main may carry unreleased behavior between PRs since nothing is published until 0.6.0.
52
+ - OQ-6 (from C19): Benchmark home/run-mode → resolved by aggregation: `benchmark/` directory, benchmark-ips as dev dependency, manual-run (not CI), results published to wiki Performance page; if results contradict §8.2 the default is revisited before release (risk logged).
53
+ - OQ-7 (from C4): Spec correction for make_shareable rejection set → resolved by evidence: spec *intent* (UnshareableValue for IO) is preserved via proactive IO detection in Policy application; spec doc + README/wiki text corrected in Phase 4. Empirical re-verification on Ruby 3.2 CI is a Phase-1 spike task.
54
+ - **Spec-maturity tags:** plan-level: C1–C15, C17, C19–C25 (majority); spec-level: C16 (junior-developer), C18 (junior-developer). 2 spec-level findings from 1 specialist — **gate NOT tripped** (threshold: ≥5 from ≥3 specialists). No T# notes exist, so T#-contradiction does not apply.
55
+ - **Resolution source:** OQ-4, OQ-5, OQ-6, OQ-7, C25 — evidence/deterministic aggregation. OQ-1, OQ-2, OQ-3 — user input (batched escalation, see R2).
56
+ - **Decisions produced:** D-1, D-2, D-3, D-4, D-5, D-7, D-8, D-10, D-11, D-12, D-13 (full); D-14, D-16, D-17, D-18, D-19, D-20, D-21, D-22 (trivial). D-6 and D-9 flagged this round (C11→OQ-2, C16→OQ-1) but decided in R2.
57
+ - **Changed in plan:** Implementation Approach; Decomposition and Sequencing; RAID Log; Testing Strategy; Definition of Done; Deferred (YAGNI).
58
+ - **Next-step recommendation (deterministic):** Blocked pending user input on OQ-1/OQ-2/OQ-3 (single batched escalation); all other findings resolve to synthesis inputs. No re-engagement handoffs required — specialist outputs converge with no disputes surviving aggregation.
59
+
60
+ ## R2: User escalation (batched OQ-1/OQ-2/OQ-3)
61
+
62
+ - **Specialists engaged:** none — direct user escalation with recommendations, per Step 6.
63
+ - **New input provided:** Joel's answers to the three surviving Open Questions.
64
+ - **Claim ledger:** no new claims; three resolutions:
65
+ - OQ-1 → `:hardened` deprecated in 0.6.0, **removed at 1.0.0**. Docs state "deprecated in 0.6.0, removed in 1.0.0".
66
+ - OQ-2 → **PolicyConflict dropped entirely** (no class, no validation). Joel: "Since we're defaulting to frozen and allowing workers to opt out to something more permissive, we don't have a PolicyConflict... nothing to raise means no class, and we'll revisit later if we need to refine this." Reopening trigger: a strict-Gang feature (§11.4) that can forbid worker-level loosening.
67
+ - OQ-3 → seven-page wiki set approved as proposed (Home, Handoff-Policies, Coding-Idioms-Under-Frozen, Migration-Guide-0.6, Testing-Workers, Worker-Types, Performance); README keeps the quick tour and links into the wiki.
68
+ - **Open Questions raised:** none.
69
+ - **Spec-maturity tags:** n/a (no new findings).
70
+ - **Resolution source:** OQ-1, OQ-2, OQ-3 — user input (verbatim above).
71
+ - **Decisions produced:** D-6 (PolicyConflict dropped), D-9 (:hardened removed at 1.0.0), D-15 (seven-page wiki set).
72
+ - **Changed in plan:** Implementation Approach; Decomposition and Sequencing (PR 2 PolicyConflict removed, PR 4 wiki set); Deferred (YAGNI); Definition of Done.
73
+ - **Next-step recommendation (deterministic):** Go to synthesis — zero unresolved Open Questions, zero pending handoffs, round produced no new findings.