Files
reasampler/docs/PLAN.md
T

273 KiB
Raw Blame History

PLAN

The post-1.0 roadmap. Seventeen queued items consolidated into overlapping areas and sequenced into a Phase → Wave → Track hierarchy that implementation specialists can be dispatched against directly — plus Phase Γ, which did not come from those seventeen (it came from a direct interview, 2026-08-01) and is scoped in docs/product/instrument-control-surface.md, and Phase Ψ, which likewise did not come from the seventeen: it came from a direct list of seven defects and refinements (Daniel, 2026-08-01) and is specified inline in its own section below — there is no backing product doc for it, and Phase Ε, which likewise did not come from the seventeen: it came from a direct request (Daniel, 2026-08-02) and is scoped in docs/product/bank-package.md, and Phase Ρ, likewise a direct request (Daniel, 2026-08-02), scoped in docs/product/render-in-place.md, and Phase Λ — the Linux port of both artifacts — likewise a direct request (Daniel, 2026-08-02), scoped in docs/product/linux-readiness.md.

What this doc is, and how it relates to the others

  • docs/PLAN.md (this file) — the active on-deck specification list. Each track is written so a specialist brief is writable from this file alone: goal, consolidated source items, surface boundary, behavior, acceptance criteria, open questions, prerequisites.
  • docs/TODO-1.0.md — retained as the verbatim-provenance appendix. It holds Daniel's raw asks and every answer round, unedited; where this plan compresses a behavior bullet, that file is the backing record. Items are cited here by number (e.g. "consolidates items 1, 8, 14"). It is not a work queue any more; this file is.
  • docs/TODO.md — deferred follow-ups with recorded rationale, unrelated to the seventeen (with one flagged intersection: see "Flagged for awareness" below).
  • docs/COMPLETED.md / docs/ARCHIVE.md — doc-keeper's. When a track here finishes, its point is removed from this file and appended to COMPLETED.md with any deviation between landed code and spec noted.
  • docs/product/ — the product-design reasoning behind prior phases. Grep for a cited section rather than reading a file whole.

Worktree slug convention: p<phase>-w<wave>-t<track>-<slug>. Greek phase letters transliterate: Θ → th, Ξ → xi, Γ → g, Ψ → psi, Εe, Ρr, Λ → l. So Θ-W1-T1 dispatches into pth-w1-t1-zone-retirement, Γ-W1-T1 into pg-w1-t1-knob-interaction-law, Ψ-W1-T1 into ppsi-w1-t1-capture-range-exactness, and Λ-W2-T1 into pl-w2-t1-linux-compile-blockers (Phase Λ's two audit tracks already ran under pl-w1-t1-build-toolchain-audit and pl-w1-t2-source-runtime-audit).

Decision state

Everything carried forward from TODO-1.0.md is classified [verify] (answerable by reading code or running the DAW) or [propose] (a design call made at implementation review with a proposal, not a Daniel call); that classification is preserved per question, attached to the track that will answer it.

Read the Phase Λ paragraph below before relying on the plan-wide "nothing is unanswered" claim the Γ, Ε and Ρ paragraphs make. Λ (added 2026-08-02) carries four open [Daniel]-class forks, so that claim is no longer true of the plan as a whole; it is true of every phase except Λ, and Λ's paragraph states exactly which of its four forks gate anything.

Θ-W3-T1's two genuine [Daniel] questions — which no amount of code-reading could answer — are both ruled on and the track has landed; see docs/COMPLETED.md for the full narrative. Reload tier = Grouping B (continuous knobs live: filter cutoff/Q/ morph/drive/mod amount/key-track, every envelope stage time and level; root note, loop span, and start frame still trigger a full reload). Mid-stage rule = candidate (iv), hold normalized stage position (φ = elapsed/duration held fixed across a duration change, then advancing at 1/newDuration). Phase Γ opened six [Daniel]-class forks (Γ-F1…Γ-F6) and all six are ruled (Daniel, 2026-08-01) — the rulings are folded into the tracks below and indexed in docs/product/instrument-control-surface.md §8. Γ-F6 closed with a correction to the analysis, not merely a ruling: dynamic reported latency is routine for VST3 instruments and REAPER handles it as a matter of course; what makes the mandated restart expensive here is self-inflicted (setActive(true) calls reloadInstrument), so the cost is ours to reduce and the reduction is filed in docs/TODO.md rather than designed around.

Γ-F3 was subsequently REVERSED and a seventh fork opened AND CLOSED, all by Daniel's later rulings of 2026-08-01. Γ-F3 ("the stage-time ceiling stays 2.0 s") is replaced by "extend the stage lengths to 10s" — the ceiling moves in Γ-W1-T1. Γ-F7 (the VST3 parameter order) is RULED: signal flow"signal flow order." There is now NO unanswered [Daniel]-class question anywhere in this plan.

Phase Ε (added 2026-08-02) opened three more [Daniel]-class forks and ALL THREE ARE RULED, same day (Daniel, 2026-08-02): Ε-F1 container format — "proprietary container", the hand-rolled RSBK; Ε-F2 import target — "always lands as a new bank, with an auto suffix if name collision"; Ε-F3 import under a degraded tracking ledger — "refuse mismatched import." The rulings are folded into the tracks below and indexed at docs/product/bank-package.md §"Rulings". Two of the three landed somewhere other than the framing recommendation: Ε-F2 dropped the proposed rename prompt in favour of a deterministic suffix, and Ε-F3 reversed allow-with-confirm to refuse — carried by the framing's own counter-argument, that the accepted tracking residual contemplates one untracked capture while a bulk import strands hundreds in a single gesture. The plan-wide claim above therefore holds unqualified — no unanswered [Daniel]-class question remains anywhere in this plan, Phase Ε included — and no track in this plan is gated on a decision.

Phase Ρ (added 2026-08-02) opened three more [Daniel]-class forks and ALL THREE ARE RULED, same day (Daniel, 2026-08-02): Ρ-F1 multi-track — "refuse", one track per fire, a settled non-goal rather than a deferral; Ρ-F2 the result track's mode — "for this action which is not a capture, the result track should always go to arrange"; Ρ-F3 tail — "follow panel tail settings." The rulings are folded into the track below and indexed at docs/product/render-in-place.md §"Rulings". Ρ-F2 overrode the request's own original wording ("stays in whatever mode was active") and is the only one of the three that changed the spec: the result track is now an Arrange member unconditionally, the A/B-on-the-bench behaviour mode-following would have enabled is gone, and the ruling pulls in a two-line fix to the panel's auto-tag detector that would otherwise reverse it on the next timer tick. The plan-wide claim above therefore still holds unqualified — no unanswered [Daniel]-class question remains anywhere in this plan, Phase Ρ included — and no track in this plan is gated on a decision.

Phase Λ (added 2026-08-02) breaks that unqualified claim, and it is corrected here rather than left to be discovered. The claim as written in the three paragraphs above — "no unanswered [Daniel]-class question remains anywhere in this plan" and "no track in this plan is gated on a decision" — is now scoped to Θ, Ξ, Γ, Ψ, Ε and Ρ. It is still true of all six. It is not true of Λ. Λ opened four [Daniel]-class forks and NONE of them is ruled (Λ-F1 CI; Λ-F2 the dialog-resource route; Λ-F3 the copy-only drag-out invariant's wording; Λ-F4 the declared support floor). Each is stated with the evidence for both sides at docs/product/linux-readiness.md §"Open forks — Daniel's", and the Phase Λ section below carries them as a table. What they gate is narrow and is not a matter of judgement:

  • Λ-F2 is the only one that gates a dispatch — Λ-W2-T3 panel-dialog-resource cannot be briefed until the route is chosen, because the two routes own different files. Leaving it open does not stall the phase; it slips that one track to Λ-W4 and splits Λ-W3's verification sweep into two Linux sessions, which is the fork's actual price.
  • Λ-F4 gates the ship wave, not a dispatch. Λ-W2 can record a floor and widen it; Λ-W5 cannot ship an artifact that does not say what it runs on. Answerable any time before Λ-W5-T1 is briefed.
  • Λ-F1 and Λ-F3 gate nothing at all. Λ-F1 (CI) decides only which paragraph Λ-W5-T1 writes; Λ-F3 decides one sentence's wording inside an edit Λ-W4-T3 makes either way.
  • The other eleven Λ tracks are dispatchable against open forks, in the sense that none of them waits on a decision. What every Λ track waits on instead is a Linux box — a different kind of unknown, and the reason Λ-W3 exists.

Λ's six [Daniel] rulings of 2026-08-02 are settled and are recorded in the phase section below in the same shape as Γ's and Ε's: the instrument is in scope; SWELL is reached by dlopening REAPER's own libSwell.so rather than vendoring it; the editor is REAPER-only but every other host must degrade safely; macOS is out; the Linux artifact is shipped rather than developer-only; and a hard unlink prune is acceptable with a platform-aware confirmation. Do not re-litigate them.

Ruling 3 (Daniel, 2026-08-01) — real units at the host boundary. "The parameter values exposed to the VST host should be in real units, such that the host automation lanes report usable values." Satisfied through VST3's plain-value layer, not its wire format (which is normalized and cannot be otherwise): toPlain/toNormalized, getParamStringByValue and ParameterInfo::units. Specified at docs/product/parameter-automation.md §6.7 — the per-category unit/precision table, the one-formatter invariant, the stepCount sweep, and the resolution of the apparent conflict with the filter's re-taper prohibition. It lands entirely in Γ-W4-T1 and changes no wave boundary.

Flagged for awareness — not blocking, but decision-grade

  1. Item 15's cross-artifact seam is RESOLVED — this is no longer an unknown. The instrument is a read-only bank consumer by invariant (src/shell/instrument/CLAUDE.md), and the one previous attempt at an instrument→extension relay (S13) closed with a DEGRADED spike verdict and was deferred (docs/TODO.md). Resample required that crossing. Ξ-W2-T1 ratified Decision 1 = (1b): the editor invokes the extension's bake action directly over the VST-host bridge (NamedCommandLookup/Main_OnCommandEx), no poller, no nonce — dissolving the S13 DEGRADED verdict rather than re-litigating it. The read-only bank invariant held: the crossing is a bridge call, not a shell-side bank write. See docs/COMPLETED.md for the full narrative.

  2. The "Γ before Ξ-W2" ordering is VIOLATED, it was never Daniel's choice, and Γ now owns the correction. Daniel, 2026-08-01: "xi was started before I spun you up, we'll have to correct phase xi inside gamma. wasn't a choice." Ξ-W2-T1 (resample-bake-chain) ran ahead of this plan's sequencing claim, so the bake's settled reset scope — which enumerates parameters by name — ships incomplete: it cannot name rate, pitch offset or the limiter flag, none of which existed when it was written.

    This is no longer a scheduling constraint to honour. It is a correction obligation with a named owner: Γ-W3-T2 bake-reset-amendment. The classification costs no Daniel decision — docs/product/instrument-control-surface.md §3.4 pre-classifies all three against Ξ-W2's own ratified rule (all reset) — but the amendment must be written against what Ξ-W2-T1 actually shipped, not against what this plan predicted it would ship. Ruling 1 adds a second correction of the same shape, homed on Γ-W4-T1 rather than here: see item 3.

  3. The one-way doors are now IN-PHASE, and the sweep for them is a delivered artifact. Ruling 1 (Daniel, 2026-08-01) schedules VST3 parameter reporting inside Phase Γ, as Γ-W4-T1. Everything that participates in a parameter's normalization therefore freezes at the end of this phase rather than at the start of some later one, and anything that ought to move must move first.

    • The taper (Γ-W1-T1) — known, and the reason this phase was ordered as it was.
    • The stage-time ceiling 2.0 → 10.0 s (Γ-W1-T1) — Γ-F3 reversed by Daniel's "extend the stage lengths to 10s." A range endpoint is normalization exactly as much as the curve between the endpoints is.
    • Two further doors that need action, both new, both landing on Γ-W1-T1: every default must have an exact normalized preimage (a host's reset-to-default has no resetDeckParam bypass to use), and the filter's four *Norm controls must not be re-tapered (their laws are already wire-frozen in payload v9).
    • Six more constants freeze without needing to change, and three are already frozen for unrelated reasons; the complete sweep, with dispositions and with what was checked, is docs/product/parameter-automation.md §8. That doc is no longer scoping-only — §§610 are the specification Γ-W4-T1 is built from.

Phase-wide acceptance criteria

These bind every track in all four phases and are stated once here rather than repeated per track. Phase Γ adds a set of its own, stated in its phase header. Phase Ψ adds none — the structural heuristics, performance guardrails, and product invariants below bind it exactly as written (its phase header states its performance posture against the named hot paths).

Structural (root CLAUDE.md, Daniel 2026-07-28)

  • More directories is a must; more files is good; ~600-line file ceiling. The ceiling is the bar; a responsibility seam is the method. Bisection-to-hit-the- number is rejected. sampler_core.cpp (956 lines) is the standing documented hot-path exception — Θ-W1-T1 re-seams it, and any surviving over-ceiling TU must carry the same explicit justification.
  • Templates where earned — compile-time dedup with zero runtime cost, off the hot paths. Not for types that differ in name only.
  • SOLID is great, but saved CPU is better. No dispatch-stack blowouts anywhere; prefer static polymorphism where the types are compile-time-known.

Performance guardrails

Root CLAUDE.md's five extension-side guardrails (peaks envelope compute, audition, realtime-capture tick, JSON, FxBypassGuard) are unchanged by this plan; no track here touches them. The instrument adds a sixth surface that binds every Θ track:

  • process() — the per-voice-per-sample path — takes no new indirection. The filter tick, the envelope evaluation (staged and spline), the pitch-shift read, and the loop read all sit on it. Concrete, inlineable types only: no IEnvelope, no IFilter, no virtual per-voice tick(). A filter with two modes is a branch-predictable switch or a compile-time-known dispatch, never a vtable. Spline evaluation is a binary search over a point array, not a polymorphic curve object.
  • No allocation, no file I/O, no bridge call in process(). The off-audio-thread reloadInstrument + atomic pointer swap stays the only way new state reaches the audio thread. Every new parameter this plan adds follows that path.
  • A split that would add a hot-path indirection is out of scope — rework it or drop it.

Product invariants

  • Capture and placement are separate acts. No track here may place a timeline item. Item 15's bake explicitly must not, and one of its candidate architectures uses a temporary arrange mutation — that candidate must leave the arrange byte-identical.
  • Prune is the single, exclusive file-deletion authority. Item 15's "replace" never deletes bytes; item 17's consolidation may not weaken any protection prune has today.
  • The instrument never writes the bank. Item 15 is the first feature that needs to, and it resolves that by asking the extension, not by breaching the invariant.
  • Migration bar: a project saved before a change reopens sounding identical. Holds everywhere except item 16's genuinely-multi-zone case, where Daniel deliberately relaxed it.
  • Every pure module gets a <module>_tests target that runs without REAPER or a DAW. New pure modules in this plan are not optional-test.

Phase Θ — ReaSampler 9000: one parameter set, a filter, shapeable envelopes, a legible editor

Ships: the instrument with the zone system retired, a resonant HP/LP filter stage in the voice path, curve-shapeable and spline-drawable envelopes on all three EGs, Gate-mode loop sustain, and a re-laid, high-DPI-clean editor — plus the two extension drag/drop defects that block getting captures into it.

Consolidates items 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16.

All seven waves have landed — Phase Θ is complete. W1 through W7 each carry their own landed note above; see docs/COMPLETED.md for every track's full narrative. Θ-W7 was opened after Θ-W6-T1 shipped, to fix two rendering defects Daniel found by eye; it did not exist in the plan when this phase was originally scoped.

The organizing constraint. Three surfaces in the instrument are single-writer by nature and dictate the wave shape:

  1. The parameter model (zone_params.h + component_state_io) — every parameter addition touches both. Two tracks adding parameters concurrently is a merge fight and two competing ComponentState version bumps.
  2. The voice render path (sampler_core.cpp) — filter insertion, envelope evaluation, loop read, and the Trigger tail all live there.
  3. The Sample face (editor_paint_sample.cpp 516 lines / editor_input_sample.cpp 583 lines) — every UI item repaints it, and both are already at the ceiling.

Parallelism in this phase therefore comes from splitting the Sample face into bands (done once, in Θ-W1-T1) and from extension-side work being genuinely disjoint — not from running two parameter-model tracks at once. Where a wave has one track, the collision is real and the serialization is the correct answer.


Θ-W1 — Collapse and re-seam

All three tracks have landed — Θ-W1-T1 (zone-retirement), Θ-W1-T2 (capture-handoff-bugs), and Θ-W1-T3 (filter-dsp-port) — see docs/COMPLETED.md for the full narrative of each. Between them: the zone subsystem is retired, the wave's two responsibility seams (the sampler_core split, the Sample-face band split) are in place, the two extension-side capture-handoff defects are fixed, and the filter DSP has landed as a standalone pure module — Θ-W2-T1 has since wired it into the voice path (see below).


Θ-W2 — Filter in the voice path; waveform and chrome bands

Depends on W1 for: the one-parameter-set model and the sampler_core seam that T1 writes the filter into; the filter module T1 wires up; the Sample-face band split and band-stack allocator that T2 and T3 fill; and W1-T1's key-range answer, which decides what T3's piano strip displays. Authoring any of this against the per-zone model means writing storage plumbing W1 deletes.

All three tracks have landed — Θ-W2-T1 (filter-voice-path), Θ-W2-T2 (stereo-waveform-lanes), and Θ-W2-T3 (toolbar-and-piano-strip) — see docs/COMPLETED.md for the full narrative of each. Between them: the filter sits in the per-voice signal path with its own deck, the waveform band shows both channels in stereo mode behind a type-enforced full-height overlay contract, and the chrome band's toolbar and piano strip are cleaned up per spec. The three tracks were disjoint by band — T1 owned the parameter model and the deck band, T2 owned the waveform band, T3 the chrome band — and none re-allocated the band stack.


Θ-W3 — Live parameters, then the staged envelope system

Depends on W2 for: the filter envelope's existence — items 1, 8, and 14 govern three envelopes, and the filter is the third; and for the Filter deck, which must exist before it can receive a corner radio switch and inner curve dials. T1 additionally depends on W2-T1 for the filter itself: the filter is the first control set where the latched-at-note-on delivery model fails audibly, and it is what made the defect visible.

Both tracks have landed — Θ-W3-T1 (live-parameter-delivery) and Θ-W3-T2 (staged-envelope-curves) — see docs/COMPLETED.md for the full narrative of each. Between them: every continuous playback control (filter cutoff/Q/morph/drive/mod amount/key-track, every stage time and level on all three envelopes) now reaches a sounding voice live instead of latching at note-on, and the envelope-overlay editor grew from an amp-only fixture into the shared graphical surface for all three envelopes — a corner radio switch per deck (none active by default), curve-shapeable segments on every sloped stage (0.110 exponent, an inner dial paired with an overlay knot), the release-right-anchored AHDSR layout against the pitch envelope's 1:1 AHD, and the Trigger amp/filter fade pair folded into a Trigger AHD, consolidating what were two staged-shape mechanisms into one. The Trigger × Preserve end-of-sample click is fixed; the landed fix is wider than scoped, also ringing out Gate × Preserve × source-exhaustion, previously a hard cut. The ordering was deliberately serial — T1's live-delivery mechanism landed first so T2's new curve exponents and Trigger AHD fields were authored directly into it rather than backfilled afterward.


Θ-W4 — Loop sustain and the velocity deck

Depends on W3 for: the envelope system both tracks compose with — T1's loop-sustain is the Gate-mode sustain the AHDSR releases out of, and T2's bipolar pitch/filter curves modulate targets whose envelopes W3 just reshaped. T2 additionally depends on W2-T1 for the filter's existence and on W2-T3 for the preview button's toolbar position.

Both tracks have landed — Θ-W4-T1 (gate-loop-sustain) and Θ-W4-T2 (velocity-deck-and-bipolar-curves) — see docs/COMPLETED.md for the full narrative of each. Between them: loop points are now a usable feature, with a Gate-mode loop acting as the sustain and a parameterized crossfade at the seam; and the three velocity-curve popups (amp, pitch, filter) now live together in a new VELOCITY deck group, with the pitch and filter curves bipolar and flat-by-default so their modulation is off until drawn, while the amp curve stays unipolar and unchanged, and the preview button's text is replaced by a drawn play-triangle glyph. Params payload reached v11 with T1's loop block and v12 with T2's velocity→pitch curve appended after it.


Θ-W5 — Spline EGs

Depends on W4 for: the bipolar velocity-curve domain (W4-T2) — the spline algorithm is singly implemented and multi-referenced, so its enhancement must land against the final consumer set, and the last consumer to change domain is the pitch/filter velocity curve; and for the Gate-mode loop (W4-T1), since "Gate is unavailable in Spline mode" is only a real, testable rule once Gate has something to be unavailable for. It also depends on W3's radio switch, which is how a spline contour reaches the overlay at all.

Θ-W5-T1 has landedspline-egs — see docs/COMPLETED.md for the full narrative. It shipped a free-drawn alternative to every staged envelope: pitch, filter, and amp EGs can switch Staged → Spline and have their contour drawn directly on the waveform overlay. The one shared monotone-spline implementation gained hard points — sharp corners, no smoothing on either adjacent segment — flowing to every consumer including the existing velocity→amp transfer curve, no fork. Both Staged and Spline state persist simultaneously (saved-but-inactive, lossless round-trip); params payload reached v13, and v12 projects still load. Gate is unavailable while a Spline EG is active; the contour is a pure time function over the full sample length, normalized and drawn 1:1 with the sample's time axis. Point grammar converged on left-click add / right-click delete / control-click hard-smooth toggle, one grammar across both spline consumers. Staged segment knobs and their inner curve dials render disabled and reject edits while Spline is active. Point-count ceiling: 128, a musical bound rather than a performance one. A follow-on change in the same track reworked deck cell width: -1 in cellIds now means one cell's width, reserved and redistributed, rather than a blank cell holding geometry — a Trigger face that drops Sustain and Release gets wider cells instead of dead slots; group widths, row packing, deck height, and Gate-mode cell widths are unchanged.


Θ-W6 — Editor legibility pass

Depends on W5 for: the last change to a drawn surface. Item 13 is an audit whose output is a disposition list over "every class of drawn surface," and item 10's sizing pass is judged by eye over the finished layout — running either while the spline contour, the disabled-knob state, or the deck inventory was still moving would have meant auditing and then re-auditing. Θ-W5-T1 has landed (see docs/COMPLETED.md), so that surface has stopped moving and this dependency is satisfied. The source doc names this sequencing as an observation (item 13 after the layout/knob work); this plan adopts it as the boundary.

One track. Both items repaint essentially every surface in the editor; concurrent tracks would collide everywhere.

Θ-W6-T1 has landedlegibility-and-antialiasing — see docs/COMPLETED.md for the full narrative. It shipped both halves together: knobs grew 28→40 px (inner curve dial 14→20), the deck cell 48×58→60×74, and the label band 12→16 px, now drawn in Font::Label rather than Font::Micro — group captions and toggle segments deliberately stayed Font::Micro, since bumping them would outgrow row 1's headroom at the floor width. The editor's default/minimum size grew 840×620→980×680 to fit the wider deck at floor width; an existing saved instance's window grows on open, and the floor is validated by a derived test rather than literals. All fourteen time-constant labels now read in ms (display-only; internal representation untouched) — holdFraction knobs, Len %, and the bank panel's duration readout stayed out of scope. Double-click resets each ring independently — outer ring resets the value, inner dial resets the exponent to 1.0 — reaching the chrome's preview-velocity knob too via a shared inKnobFace rule.

The antialiasing audit fixed knob arcs and needle, the inner dial arc and needle, staged and spline envelope slopes, the spline contour, the velocity-popup trace, the waveform outline, and the preview triangle; node handles, curve knots, knob discs, buttons, piano keys, loop markers, borders, gradients, and text were already clean. The disposition table is a standing artifact in docs/product/visual-design-language.md §8. The piano-key open question is answered: not aliasing — every key is an axis-aligned LICE_FillRect, so the earlier width defect was integer-division residue in the tiling, not a sloped edge. High-DPI host scaling itself is unverified (deferred, see docs/TODO.md).

All visual outcomes remain pending Daniel's by-eye sign-off on dev.


Θ-W7 — Arc-and-spline antialiasing fix

Depends on W6 for: a drawn surface to find a defect on — this wave did not exist in the plan; it was opened after Daniel found two rendering defects by eye once Θ-W6-T1 shipped, so the audit's own output is what surfaced them.

One track.

Θ-W7-T1 has landedarc-and-spline-aa — see docs/COMPLETED.md for the full narrative. The stacked-LICE_Arc knob and dial rings never reached an opaque core (peak alpha measured 138/255), and the staged/spline envelope and velocity-curve traces were fully aliased rather than gapped, from integer cy quantizing the slope. Both defects, plus the two needles, now route through one pure analytic thick-stroke rasterizer — coverage in a new core/ui/stroke_aa, the single LICE blend in a new shell/instrument/editor_stroke — replacing LICE_Arc and LICE_ThickFLine outright. Measured: arc peak alpha 138/255 → 255/255, arc perpendicular-weight ripple 67% → 5%, spline weight ripple 29% → 3%, at a cost of +0.09 ms per full editor repaint. Daniel then ruled every sub-2 px stroker width up to 2 px, since the stroker only guarantees an opaque core at width ≥ 2 px; the knob track arc, the inner-dial needle, and the deck's mini velocity trace each moved 1.0 → 2.0 px. docs/product/visual-design-language.md §8 is corrected — a false "keeps every ring antialiased" claim is deleted, the rows are re-dispositioned with measurements, and the audit's methodological lesson (verifying which primitive was called is not verifying what it rasterized) is recorded as a standing blockquote. All visual outcomes remain pending Daniel's by-eye sign-off on dev.


Phase Ξ — The resample loop

Ships: one consolidated, fully robust provenance/usage tracking system, and on top of it the one-click in-sampler resample — dial → bake → dial again, with the bank as the medium each iteration passes through.

Consolidates items 15, 17.

Why these two and not more. Item 17 is a prerequisite of meaning for item 15's bank-side half: the replace-vs-add rule is "does provenance-tied usage exist," which denotes nothing until the consolidated lineage records exist. Item 17 is otherwise independent of the editor chain — which is what makes Ξ-W1 concurrency-safe with Θ.

Ξ-W2 onward requires Phase Θ complete. Item 15 presupposes the processing surface it bakes — "filtering, pitching, amp all set up nice" is the instrument items 1, 2, 3, and 14 build — and its settled reset scope enumerates the filter parameters, the spline contours, and the loop points by name. Baking a processing chain that does not exist yet is not a schedule preference; the feature is not expressible. Phase Θ landed as of Θ-W7-T1 (see docs/COMPLETED.md); this gate is satisfied.

All three waves have landed — Phase Ξ is complete. W1 through W3 each carry their own landed note below; see docs/COMPLETED.md for every track's full narrative.


Ξ-W1 — Consolidated tracking, and the programmed-note model

Ξ-W1-T1 — tracking-consolidation

Landed — see docs/COMPLETED.md for the full narrative. The provenance/usage territory is now one system: a new src/core/tracking/ directory holds origin_ledger (the record family — OriginRecord/OriginKind, the insertion-ordered OriginLedger, its JSON codec, and the Fresh/Loaded/Unreadable/FutureVersion load classification) and tracking_authority (the one decision surface: pruneProtection and tiedUsageExists), retiring core/model/owned_manifest. Both prune's protected set and the resample's replace-vs-add decision are computed from one borrowed TrackingState, so the two safety-critical consumers cannot drift apart. sample_usage deliberately stays in core/wire — the consolidation is of the decisions, not the codecs. The deferred persisted-instance-identity fix was not folded in — the deferral is restated in docs/TODO.md, its one home. This track landed OriginRecord's birth-time parentSampleId chain as the lineage mechanism, but Ξ-W2-T1's "naming and lineage" open question (jointly held with this track) is unaffected and still theirs to close — display naming and user-readable iteration lineage were not decided here.

Ξ-W1-T2 — note-program-model

Landed — see docs/COMPLETED.md for the full narrative. The programmed-capture- signal model is a new pure module directory, src/core/instrument/note/ — a fourth peer of engine//map//ui/ under core/instrument/ — holding musical_division, tempo, and note_program (Velocity, the denominated OffsetAmount, the anchored StartOffset/EndOffset, NoteProgram, resolveNote). Both open questions below are answered, for Ξ-W3-T1: negative offsets are legal in both directions (sign uniform, positive is later in time; only an inverted window is refused, reported via ResolvedNote::windowCollapsed), and the denomination seam is confirmed as of this landing — note length stays musical-division-only, and an offset stores the denomination it was entered in. Superseded by Ξ-W3-T1's landed work (see docs/COMPLETED.md): note/CLAUDE.md's musical-division-only rule is amended — a note length now carries EITHER an exact derived duration or a musical division, not division-only. The offset denomination rule is unaffected.


Ξ-W2 — The bake chain

Depends on Ξ-W1 for: the consolidated lineage records that make replace-vs-add computable (T1) and the programmed-note record the offline pass renders (T2). Also depends on all of Phase Θ — see the phase note above.

This wave ran AHEAD of Phase Γ, and that was not a choice. The plan asserted Γ must land first so the bake's reset list would be complete on the day it shipped; Ξ-W2-T1 was already live. The consequence is owned, not absorbed: Γ-W3-T2 bake-reset-amendment completes the list afterwards, and Γ-W4-T1 adds the host-notification obligation once parameters exist. Neither is this wave's work, and neither is a defect report against it.

One track. The bake is one gesture and one chain; the architecture decision at its head governs every step after it.

Ξ-W2-T1 — resample-bake-chain

Landed — see docs/COMPLETED.md for the full narrative. The architecture decision (this track's first deliverable) is ratified: Decision 1 = (1b), the editor invokes the extension's bake action directly over the VST-host bridge (NamedCommandLookup/Main_OnCommandEx), no poller/nonce, dissolving the S13 DEGRADED verdict; Decision 2 = (2c), the instrument renders in-process and the extension banks the file, taken over the plan's leaning toward (2a) on an engine-version-skew argument. Extension presence resolved to cleanly-unavailable, not silently lossy — the affordance refuses up front when the extension is not loaded. Reset-scope edge cases classified against the ratified rule: play mode → RESET to Trigger, start point → RESET, channel mode and preview velocity → SURVIVE. Phase Γ's additions remain NOT this track's — Γ-W3-T2 bake-reset-amendment still amends the reset list for Γ's own new values, unaffected by this landing. Naming and lineage is proposed (KickKick r2Kick r3) but not itself ratified — still open jointly with Ξ-W1-T1's lineage-record question, which Ξ-W1-T1 answered on its own side (OriginRecord::parentSampleId). Nothing was verified in a live REAPER session; Daniel's manual verification is still owed.


Ξ-W3 — The capture-signal popup

Depends on Ξ-W2 for: the bake chain this track was originally scoped to build a preview against, under the acceptance criterion "preview and bake cannot diverge." That motivation is retired, not satisfied — see below.

One track.

Ξ-W3-T1 has landedcapture-signal-popup — see docs/COMPLETED.md for the full narrative, and it diverges substantially and deliberately from this section's spec. The popup this track was scoped to build (a musical-division note-length picker, ms/beat- editable offsets, velocity, and a preview trigger) was built (~1500 lines) and then abandoned unmerged on Daniel's ruling: "I didn't realize you had already derived a usable window. The manual stuff for baking a specific midi length was just an idea, if we have a smarter, fewer-clicks way of doing it, that is ideal. I just don't want to lose anything when we bake. We can abandon the whole parameterized bake window if we can safely derive the window in gate and trigger modes." An audit, backed by executable tests, established the window derives losslessly everywhere except Gate over an active sustain loop, which has no intrinsic duration to derive. What shipped instead: the bake window derives itself in Trigger and loop-less Gate; one control, a musical-division Hold picker, covers the one irreducible case and is shown only for Gate-with-active- loop (bakeWindowNeedsHold, reading the engine's own loop fold); bake velocity now reads the instance's persisted preview velocity rather than a hard-coded value; and the chrome-row play button stays a pure MIDI trigger — Daniel's ruling: "play button is pure MIDI trigger, Bake parameters are their own thing." This retires this section's acceptance criterion 2 ("preview and bake cannot diverge") and its preview-trigger behavior bullet by ruling, not by shortfall — there is no popup and no preview trigger. Three pre-existing truncation bugs were found and fixed along the way, and note/CLAUDE.md's musical-division-only invariant is amended (see Ξ-W1-T2 above): a note length now carries either an exact derived duration or a musical division, not division-only.


Phase Γ — The instrument's control surface

Ships: the deck reflowed into two categorical rows with a double-height MASTER bus deck, a PITCH/RATE deck with playback-rate and baseline-pitch controls, a master limiter with dynamic reported latency and a real output meter, one consistent knob interaction/taper law across every variable control over a stage-time range raised 2 s → 10 s, a fix for staged contour traces drawing straight, a re-approached loop/crossfade marker UX under an explicit chrome-row loop enable, the Phase Ξ bake's reset list corrected, and — as the phase's last track — the instrument's first VST3 automatable parameters, reported to the host under a frozen id contract.

Consolidates: none of the seventeen. Phase Γ came from a direct interview with Daniel (2026-08-01); the product reasoning, the measured layout table, the invariant collisions and the fork rulings are in docs/product/instrument-control-surface.md, and the parameter system's is in docs/product/parameter-automation.md §§610. Read §1.2 (the layout table) and §7 (collisions) before dispatching any track here — every number in this phase is derived there, and docs/TODO.md's old deck-rework geometry is superseded.

Fork state — SEVEN ruled, ONE OF THEM LATER REVERSED, NONE OPEN. Indexed at spec §8, folded into the tracks below:

  • Γ-F1kEditorMinHeight stays 680.
  • Γ-F2 — the limiter has lookahead with DYNAMIC reported latency (zero when off, the lookahead when on, reported to host PDC). This inverted the product recommendation; W1-T2's scope grows accordingly — spec §3.1.1.
  • Γ-F3 — RULED, THEN REVERSED THE SAME DAY. First ruled "the ceiling stays 2.0 s in this phase"; then Daniel: "extend the stage lengths to 10s." kEnvTimeMaxSeconds / kGateStageMaxSeconds move 2.0 → 10.0 in Γ-W1-T1, and the docs/TODO.md entry that carried the ambition is discharged rather than deferred. The reversal's cause is Ruling 1 — parameters now ship in-phase, so the ceiling is a one-way door that must be walked through before them, not after. Spec §4.3.1.
  • Γ-F4 — there is an explicit loop enable, and it lives on the chrome row, not in a deck. W2-T2's scope grows accordingly — spec §6.4.
  • Γ-F5 — MASTER's reserved slot is one cell. The 90 px headroom argument behind that is spec §1.6 and governs every future control addition.
  • Γ-F6ship dynamic latency as ruled. The restartComponent(kLatencyChanged) deactivate/reactivate the SDK mandates is accepted: "the limiter will either be on or off on its instance, toggling during playback is not a use case." No constant-latency fallback, no measurement gate. This ruling also corrected the analysis — spec §3.1.1.
  • Γ-F7 — RULED: SIGNAL FLOW. Daniel, 2026-08-01: "signal flow order." The VST3 parameter order — both the frozen id numbering and the getParameterInfo presentation index — is PITCH/RATE → PITCH ENV → FILTER → FILTER ENV → AMP → VELOCITY → VOICE → MASTER, the deck's own sampleDeckGroups rule, with each group's cells in the semantic order the id table freezes. The editor's visual rows after the reflow were the rejected alternative. The reason, because a future reader will ask why the id order does not match the screen: the editor's layout has already moved twice (Θ-W6-T1 grew the floor 840 → 980; Γ-W3-T1 takes it to 1190 and re-rows every group) and within-row order is settled by width fitting, not by meaning — so binding a permanently-frozen id order to a demonstrably mobile layout guarantees the two drift apart, after which the order is neither logical nor matching. Signal flow is the axis that does not move. Full argument and the accepted residual cost: docs/product/parameter-automation.md §6.4; the resulting 44-id table is stated at §6.2.

Ruling 1 (Daniel, 2026-08-01) — VST3 parameter reporting ships in this phase. Verbatim intent: "correct the phase gamma plan to account for complying with the VST3 standard for parameter reporting… by the end of gamma we have the automatable params reported. Make the parameter order logical." docs/product/parameter-automation.md was written as scoping and has been promoted in place: §§15 are the original analysis, §§610 are the specification Γ-W4-T1 is built from. Four things it decides that the scoping pass left open: the blob stays authoritative and parameters are a third surface onto the one model (§6.1); the id space is an independent, hand-assigned, FOREVER-FROZEN table, now stated in full as 44 numbered rows in signal-flow order (§6.2, §6.3); the exposed list is derived from the three-state commit predicate, never hand-maintained (§7); and, under Ruling 3, every parameter's plain unit, range and display precision (§6.7). Today the plugin has zero parameters — ReaSamplerProcessor::initialize (reasampler_processor.cpp:56-73) never populates SingleComponentEffect::parameters, so getParameterCount() returns the SDK default 0.

What the Γ-F6 ruling changed in the analysis, not just in the plan. Dynamic latency reporting is routine for VST3 instruments and REAPER handles it as a matter of course; the SDK's deactivate/reactivate requirement (pluginterfaces/vst/ivsteditcontroller.h:105-108) is the normal contract, not an exotic one. What makes the cycle expensive here is entirely our own doing: ReaSamplerProcessor::setActive(true) calls reloadInstrument() — a bridge read plus a full WAV re-decode (reasampler_processor.cpp:89-97) — where a typical plugin's setActive only allocates and frees buffers, and the deactivate side's freeing of live_/draining_/graveyard (:98-107) is likewise our own design. The cost is therefore ours to reduce if it ever matters, and the reduction is decoupling reload from activation — not abandoning dynamic latency. That improvement is filed as a docs/TODO.md entry with its trigger condition; it is not scheduled in this phase.

Sequencing against Phase Ξ — the ordering claim is RETIRED and replaced by an owned correction. This plan previously asserted that Γ must run before Ξ-W2 and called it "a correctness point, not a preference." Ξ-W2-T1 ran first. That was not a decision anyone took — the track was live before this phase existed (Daniel: "xi was started before I spun you up, we'll have to correct phase xi inside gamma. wasn't a choice."). So:

  1. The bake's reset list is incomplete as shipped, and Γ-W3-T2 amends it. Rate, pitch offset and the limiter flag are all reset under Ξ-W2's own ratified rule (spec §3.4), so no Daniel decision is owed — only the edit, and it must be made against what Ξ-W2-T1 actually shipped rather than against what this plan predicted it would ship.
  2. Ruling 1 adds a second correction of the same shape, and it lands one wave later. Exposing the reset-class values as VST3 parameters means the bake's reset must notify the host, and a host automation lane on a reset-class parameter re-imposes its curve onto already-baked audio. Both are Γ-W4-T1's acceptance criteria — that track creates the condition, so it carries it (docs/product/parameter-automation.md §9).
  3. The payload-ladder half of the old claim needs re-checking, not restating — see the ladder block below, which now states rungs relatively rather than by number.

The organizing constraint. Six surfaces are single-writer and dictate the wave shape: ui/deck_values.cpp and the taper module extracted from it (the taper law and the new ceiling, then the two new controls, then the host normalization — three tracks, three waves), editor_paint_waveform.cpp (the contour trace, then the loop marks), ui/deck_groups.cpp (the row predicate, then the PITCH/RATE descriptor, then the reflow's row consumption — three tracks, three waves), engine/voice.cpp (the Preserve read path, then the rate compounding into it), shell/instrument/reasampler_processor (the limiter chain and latency, then the parameter surface), and the params-payload ladder. Every wave boundary below is one of those collisions, not a preference. Where a wave has more than one track, the tracks are disjoint by surface.

Resequenced 2026-08-01 (Daniel), three changes. The prior four-wave shape put the reflow at W3 and the Preserve stretcher at W4; both moved. Ruling 1 then added a fourth wave — see "The wave shape after Ruling 1" below.

  1. The reflow is split, canvas from arrangement. The window floor and the width budget it is derived from land early (Γ-W1-T4), so every other UI track in the phase is drawn, tested and judged at the final 1190 × 680 window instead of at a size a later wave changes under it. The two-row arrangement stays late (Γ-W3-T1), because it can only be measured once the final PITCH/RATE and MASTER descriptors exist. The seam is stated at Γ-W1-T4.
  2. preserve-time-stretch moved W4 → W1-T5. It is the longest pole in the phase and has zero dependency on any UI work — a pure core/instrument/engine/ module. Scheduling it last was a scheduling error. Consequence: it is no longer Rate's successor but its prerequisite, which retires the interim resample-and-cancel stand-in entirely — see Γ-W2-T1.

Net: four waves become three, and both of the phase's DSP unknowns (the limiter, the stretcher) are exposed in wave 1 rather than one of them landing last.

The wave shape after Ruling 1 — three waves become four. The parameter system cannot be a track inside any existing wave, and the reason is a chain of hard prerequisites, not caution:

  • after W1-T1, because the taper and the 10 s ceiling are the host-facing normalization, and Γ-W1-T1 is also what extracts them into the one module the host will read through;
  • after W1-T2 and W2-T1, because every control that could be a parameter must exist before the list is declared — the list is derived from the control inventory, and an inventory that is still growing produces a list that has to be re-frozen;
  • after W2-T1 specifically, because isLiveDeckParam becoming three-valued is the prerequisite of the classification, not an incidental of it: the exposed set is exactly Live NoteOnLatched;
  • after W3-T1, because MASTER's inventory (the limiter toggle, the GR bubble, the reserved cell) is the last change to what controls exist at all;
  • after W3-T2, so the bake's reset list is already complete when Γ-W4-T1 adds the host-notification obligation over it — one amendment instead of an amendment to an amendment.

The result is a single-track Γ-W4, which is the right shape for it anyway: the storage decision governs every part of the work, exactly as Ξ-W2-T1's crossing decision governs its chain. And it satisfies Daniel's own framing literally"by the end of gamma we have the automatable params reported."

The params-payload ladder — re-checked, and now stated RELATIVELY. The old block named v14 and v15 as absolutes. That is no longer safe to assume, because Ξ ran ahead of its sequencing and this plan is not the record of what Ξ-W2-T1 actually took. On dev today kParamsPayloadVersion is 14 (map/component_state_io.h:163) and Ξ-W2-T1 was specced to take no rung — but the plan's prediction is not evidence. So:

Γ owns the next three rungs above whatever dev carries when Γ-W1-T2 dispatches, and that number is READ, not assumed. In order: the first rung to W1-T2 (the limiter enable flag), the second to W2-T1 (rate + pitch offset), the third RESERVED for W4-T1 — spent only if the storage-architecture verification forces a persisted field, which the specification says it will not (docs/product/parameter-automation.md §6.1, §10). If unspent, that rung falls through to the next phase unclaimed.

On dev as of 2026-08-01 that resolves to v15 / v16 / v17-reserved. Ξ-W3-T1 landed and consumed a rung (v14, the bake Hold division) — not Ξ-W2-T1, which took none as specced — so every number shifted by one and nothing else about the ownership changes — which is the whole point of stating it relatively.

Every other track in the phase owns no rung: W1-T1 changes no persisted field (the payload stores raw engine doubles, so both the taper and the new ceiling are persistence-neutral), W1-T3, W1-T4 and W1-T5 add no field, W2-T2's loop enable maps onto the already-persisted SampleLoop::hasLoop, W3-T1 is layout only, and W3-T2 changes a reset list, not a format.

The editor's deck is knowingly mis-composed between Γ-W1-T4 and Γ-W3-T1, and that is not a defect report. Raising the floor without the reflow leaves the greedy whole-group wrap packing two ragged left-aligned rows with categorically wrong membership. Γ-W1-T4 states the exact interim layout; do not "fix" it in a track that does not own it.

Phase-wide acceptance criteria (in addition to the ones stated at the top of this file):

  • Bypassed means byte-identical. With the limiter off, the per-sample output path is byte-identical to today's bare ramped multiply — the same discipline that makes live == nullptr byte-identical to the pre-live core and the filter's exact skip at modAmount == 0 hold the at-rest path unchanged.
  • No ComponentState sound change. A project saved before this phase reopens sounding identical: absent rate lifts to 100 %, absent pitch offset to 0 st, absent limiter flag to bypassed. Re-tapering a knob and raising the stage-time ceiling (both Γ-W1-T1) change needle angles only — the payload stores raw engine doubles, so saved values reload bit-identical, and a 3 s stage saved at the old ceiling is simply unreachable-by-hand rather than altered.
  • kVelocityPitchRangeSemitones / kPitchDepthMaxSemis (24.0) does not move. It is load-bearing in the v12 wire format. The new Pitch knob reads it; it does not mint a second ±24 constant. From Γ-W4-T1 it is also a frozen host normalization — one more reason, not a new rule.
  • The taper has exactly ONE home and three consumers, and from Γ-W4-T1 the taper IS the host's toPlain/toNormalized. Γ-W1-T1 extracts it into a pure module; the knob's needle (deck_values), the AHDSR overlay's schematic axis (envelope_overlay + envelope_edit), and the host's normalizedParamToPlain / plainParamToNormalized (Γ-W4-T1) all call the same function. Three functions that agree today is a defect, not an implementation choice — the failure it prevents is a host automation lane that means one value and a needle that draws another. Under Ruling 3 this stops being an analogy: toNormalized is not like the taper, it is the taper (docs/product/parameter-automation.md §6.7.3).
  • ONE formatter per unit category, and the editor and the host are both its callers. The formatter is pure and returns the digits of a plain value in that category's single units string — no embedded unit, no magnitude-switched unit, no width-conditional abbreviation, no caller-side branch. The editor's knob label and getParamStringByValue read the same function. The editor and the host printing different text for the same stored value is a defect class, forbidden structurally rather than caught at review — same discipline, same reason, as the taper criterion above. Consequences (existing formatters stop embedding their unit; cutoff's k abbreviation is retired; the curve dial's ^ is static cell chrome, not value): spec §6.7.2.
  • Every default value has an EXACT normalized preimage under its own taper. Binds Γ-W1-T1 (which designs the taper) and Γ-W4-T1 (which declares ParameterInfo::defaultNormalizedValue). resetDeckParam bypasses the taper; a host's reset-to-default cannot, so exactness in the map itself is the only thing that makes the editor's reset and the host's reset land on the same value. Ruling 3 tightens this twice and adds one non-requirement (§6.7.7): defaultNormalizedValue is computed as toNormalized(default), never written as a normalized literal; the assertion is made on toPlain(defaultNormalizedValue), the pair the host actually calls; and toNormalized(toPlain(n)) == n at arbitrary n is explicitly NOT required — no log map satisfies it in double, and demanding it would over-constrain the taper for nothing.
  • Nothing in this phase may re-map the filter's four normalized controls. Cutoff, Q, morph and drive persist as *Norm doubles in payload v9 — their laws are already wire-frozen, and re-tapering them would re-tune every saved project independently of automation. The snap-unit table names them; that is display, not law. This does NOT conflict with Ruling 3's real-unit requirement, and the two must not be read as a collision: toPlain is a pure read-side mapping that never touches the stored value, so reporting Hz / Q / drive depth means calling filterCutoffHzFromNorm and its peers, not replacing them — which the editor's own labels already do today. The prohibition forbids editing those laws; the requirement is satisfied by calling them. One additive gap: drive has no published inverse and filterNormFromDriveDepth must be added beside the two that exist — the analytic inverse of a frozen law is not a change to it. Spec §6.7.5.
  • Shift-snap is a drag rule; stepCount is a parameter property; they are independent. The editor's snap grid must never be exposed as ParameterInfo::stepCount — that would quantize the parameter itself, permanently and for the host's automation too, freezing the grid into the forever contract and putting continuous cents out of reach from a lane. All 44 exposed parameters ship stepCount = 0, swept and confirmed, and the coincidence is structural: every discrete control is reload or rebuild tier and therefore omitted by the predicate. Spec §6.7.6.
  • From Γ-W4-T1, the parameter-id table is FOREVER-FROZEN, on the same footing as the extension's "STABLE_FOREVER_STRING" command ids, the two VST3 class UIDs, and the params-payload field order. No id is reassigned, reused or re-pointed; no exposed parameter's normalization ever changes; a retired control's id is retired with it. Full wording: docs/product/parameter-automation.md §6.3.
  • The exposed parameter set is DERIVED, never hand-maintained. A control is a parameter if and only if its commit class is Live or NoteOnLatched. There is no second table beside isLiveDeckParam / liveCommitFor, and no list that can drift from it.
  • The window floor is 1190 × 680 and must not exceed 1280 × 720. Γ-W1-T4 sets it, in wave 1; no other track in the phase may move it, and from that point every track is authored and judged at it. A track that pushes the floor past 1280 has failed, not overrun. kEditorMinHeight stays 680 (Γ-F1). The remaining 90 px of width headroom is the budget for the life of this layout — one deck cell is 60 px, so there is room for exactly one more, once. Spec §1.6 states the ledger; read it before adding any control. Chrome-row additions are a separate purse (they are paid for out of the title slot, not the floor) and must not be charged against this one.
  • Reported latency is zero unless the limiter is on. getLatencySamples() returns 0 with the limiter bypassed, in every track and at every point in the phase. Only W1-T2 may introduce a non-zero value, and only under the limiter-on condition.
  • Geometry stays pure. Every new layout, cap, label and hit-test rule lands in a pure CTest-covered module (knob_deck, sample_bands, waveform_view), never in a painter.

Γ-W1 — Foundations

Depends on: nothing in this phase.

All seven tracks have landed — Γ-W1-T1 (knob-interaction-law), Γ-W1-T2 (master-bus-audio), Γ-W1-T3 (contour-trace-curves), Γ-W1-T4 (editor-floor-and-row-law), Γ-W1-T5 (preserve-time-stretch), Γ-W1-T6 (exhaustive-switch gate on pure libraries), and Γ-W1-T7 (psola-preserve) — see docs/COMPLETED.md for the full narrative of each. T6 has no subsection below, matching this plan's original choice not to give it one; its record in docs/COMPLETED.md is reconstructed from the CMake change and its enforcement call site rather than from a spec section here.

Γ-W1-T1 — knob-interaction-law

Landed — see docs/COMPLETED.md for the full narrative. One consistent interaction and taper law across every variable control, landed before Rate/Pitch or any VST3 parameter existed so both are authored into it rather than retrofitted. The taper is extracted into its own pure module, core/instrument/ui/param_taper — the one home three consumers read (the knob's needle, the AHDSR overlay's schematic axis and its drag inverse, and — from a later wave — the VST3 host's toPlain/toNormalized). Shift snaps to whole units in the control's displayed category; Ctrl scales the drag by 0.05; Shift+Ctrl resolves to Shift; a mid-drag modifier transition re-anchors value and cursor position. The stage-time ceiling moves 2.0 s → 10.0 s (kGateStageMaxSeconds/kEnvTimeMaxSeconds, moved together so they cannot drift), reversing Γ-F3 on Daniel's later ruling, and every default now has an exact normalized preimage under its own taper — the requirement Γ-W4-T1's defaultNormalizedValue depends on, since a host's reset-to-default has no resetDeckParam bypass to fall back on. The filter's four *Norm controls stay untouched (wire-frozen in payload v9); the change is persistence-neutral throughout.

Γ-W1-T2 — master-bus-audio

Landed — see docs/COMPLETED.md for the full narrative. The master bus: a bypassable true-peak limiter (baked 0.3 dBTP ceiling, default off, no makeup gain, stereo-linked, sidechain-only oversampled detection), the meter's audio and publication half, and the plugin's first latency report — getLatencySamples() returns 0 with the limiter off and the lookahead in samples with it on, driving restartComponent(kLatencyChanged) on toggle. New pure modules core/instrument/engine/limiter and engine/meter_ballistics. Spent the phase's first payload rung: kParamsPayloadVersion reaches 15, appending the limiter enable flag as a strict suffix.

Γ-W1-T3 — contour-trace-curves

Landed — see docs/COMPLETED.md for the full narrative. Staged envelope segments now draw as the curve their exponent defines, closing the defect where the mid-segment knot floated off its own trace. A new pure module, curve_tessellate, draws every sloped stage through the same curve the audio's evaluators use, so the drawn stage and the sound it makes cannot diverge. All three envelopes, both play modes, every sloped stage, share the one fix; audio is unchanged.

Γ-W1-T4 — editor-floor-and-row-law

Landed — see docs/COMPLETED.md for the full narrative. Commits the editor's canvas ahead of the rest of the phase's UI work: kEditorMinWidth moves 980 → 1190 (kEditorMinHeight stays 680, Γ-F1), derived from three budget constants — the row block (1020), MASTER's reserved width (142), and the 1280 ceiling (kEditorCeilingWidth, relocated into sample_bands.h) — leaving 90 px of headroom. Row membership becomes a property of the group id via an exhaustive deckRowFor(DeckGroupId) switch (Sound / Contour / Spanning), consumed by no one yet — that consumption, and the fit inside the 1020 block, is Γ-W3-T1's to assert. No drawing code, descriptor, parameter, or audio changed in this track.

Γ-W1-T5 — preserve-time-stretch

Landed — see docs/COMPLETED.md for the full narrative. A real pitch-preserving time-stretcher for Preserve mode, moved up from a later wave (Daniel, 2026-08-01) so Rate ships onto a finished engine instead of a disposable stand-in — the interim resample-and-cancel path that had been planned for Γ-W2-T1 was not built at all. New header-only pure module time_stretch alongside pitch_shift's existing shift-ratio control; rate 1.0 is exactly one source frame per output frame with no residue, keeping the unity-ratio Preserve read bit-identical to the pre-stretch engine. No new third-party dependency, no allocation/lock in process(), no per-sample dispatch.


Γ-W1-T7 — psola-preserve

Landed — see docs/COMPLETED.md for the full narrative, including the corrected closure status below. Preserve's splices become pitch-synchronous: a new pure module, core/instrument/engine/period_detect (two-pass YIN), estimates the source's fundamental period once at load; pitch_shift's splice jump becomes the multiple of that period nearest the fixed window, so an aligned landing point exists by construction. Detection runs off the audio thread by link graph — sampler_core does not link period_detect — and an unknown period restores the fixed-window geometry byte for byte. A period is derived from the audio at load, so it is cache, not state: no ComponentState field, no payload rung. It gates the Rate control (Γ-W2-T1) on the plan's own stated principle that Rate must not ship before its Preserve engine.

Both failure modes this track set out to close are now closed. The geometry failure mode (no phase-aligned landing existing inside the search window for low material) closed at the original merge. The cadence failure mode — splices recurring faster than the output period — was left open at that point, with unresolved review findings from a later review of a follow-up fold; three remediation commits have since landed and a re-review confirmed the earlier findings closed. The closing measurement (one-machine, Debug-build) is recorded in docs/COMPLETED.md, not restated here.


Γ-W2 — New controls, and the overlay's marks

Depends on Γ-W1 for — four dependencies, two of them new:

  1. T1 ← W1-T1 (taper law). Rate and Pitch must be authored into the finished taper/modifier law, not retro-fitted into it, and the semitone taper must exist before a second semitone knob does.
  2. T1 ← W1-T5 (the stretcher) — NEW, and the reason the interim path is gone. Preserve Rate has no engine without it. Under the prior four-wave order this dependency ran the other way and was paid for with a disposable resample-and-cancel stand-in; the resequencing inverts it. Rate must not ship before its Preserve engine.
  3. T2 ← W1-T3 (the contour trace). Both write editor_paint_waveform.cpp; running them together is a merge fight in one file.
  4. T2 ← W1-T1 and W1-T4, weakly. W1-T1 also edits editor_input_waveform.cpp (the modifier read), which T2 rewrites for marker hit-test routing — serial, so not a conflict, but T2 rebases onto it. And T2's "the enable costs no window width" criterion is now asserted against W1-T4's derived floor test rather than one this track has to write.

T1 additionally inherits engine/voice.{h,cpp} from W1-T5 — a hand-off, not a conflict: W1-T5 defines the Preserve ratio seam, and T1 feeds it. Serial across waves by construction.

Disjointness — re-verified against this wave's membership, not carried over. Both tracks stayed in W2 and nothing entered or left it, so the prior finding is re-checked and stands. T1 owns the parameter model, the engine and the deck descriptors; T2 owns the waveform band's marks and their pure geometry and the chrome row's loop enable. The two are disjoint at the module level with one named exception: shell/instrument/editor_session.cpp. T1 may touch it for the third commit tier's routing; T2 owns pickedMarkers and applyMarkers there and nothing else. The partition is by function and the two do not overlap — textual merge adjacency, not semantic contention — but it is a shared file in a phase whose wave boundaries are otherwise single-writer surfaces, so it is stated rather than discovered at merge. Whichever track lands second rebases onto the first. No new in-wave adjacency was created by the resequencing: T2 touches neither deck_values nor deck_groups nor voice.

One cross-wave hand-off, new with Ruling 2 and NOT a contention. Rate's taper — linear in semitones over ±12, the stated exception to the centre-expansion law — belongs in the taper module Γ-W1-T1 extracts, since that module is the one home of every map. T1 therefore appends a law to a module a previous wave created. Serial across waves by construction, the same shape as its engine/voice.{h,cpp} hand-off from W1-T5. What would be wrong is a second taper defined inside deck_values' binding — one home, appended to, not forked.

The format ladder stays clean. The loop enable maps onto the existing SampleLoop::hasLoop, which is already persisted and whose start/end are already written unconditionally — no new field, no version bump — so T1 keeps sole ownership of the phase's second payload rung exactly as specced (v16 on this branch as of 2026-08-01, which has already integrated Ξ; read the ladder rather than assuming the number).

Γ-W2-T1 — pitch-rate-deck

Goal. PITCH becomes PITCH/RATE: three knobs (Key Trk | Rate | Pitch) under the existing Varisp|Presrv toggle, with both new controls wired through the engine.

Spec: docs/product/instrument-control-surface.md §2.

Surface boundary — owns: core/instrument/engine/play_params.h + core/instrument/map/play_seconds.h (the two new fields), core/instrument/map/component_state_io + params_payload (the phase's SECOND payload rung — v16 on this branch as of 2026-08-01, which has already integrated Ξ; read the ladder, do not assume the number), core/instrument/engine/voice.{h,cpp} (the compounding and the note-on latch), core/instrument/ui/deck_groups (the PITCH/RATE descriptor and the three-state live predicate), core/instrument/ui/deck_values (the two new bindings). Does not own the deck's row layout — that is Γ-W3-T1 — nor the time-stretcher itself (Γ-W1-T5, already landed by the time this track runs) — nor the pitch-synchronous splice geometry that gates Preserve Rate (Γ-W1-T7, also already landed by the time this track runs).

Behavior.

  • Rate: 50 %200 %, default 100 % at true knob centre, exponential taper — 50 % = 12 st, 200 % = +12 st, musically symmetric. This is linear in semitones over ±12 and is the stated exception to W1-T1's centre-expansion law (which applies to semitone knobs whose throw exceeds ±12).
  • Pitch: a baseline pitch offset, ±24 semitones, centred, on W1-T1's centre-expanded semitone taper. Reads kPitchDepthMaxSemis; does not mint a second constant.
  • Varispeed: keytrack ratio × rate ratio × pitch-offset ratio compound into a single read-increment multiply; the rate offset applies to the varispeed pitch. Composes with the pitch envelope's existing per-frame ratio_ multiply — no new per-sample stage.
  • Preserve: rate is an absolute value driving duration only; keytrack and pitch offset drive the pitch shifter. Rate drives the stretch ratio Γ-W1-T5's stretcher already consumes — there is no interim path. The stretcher is this track's prerequisite, not its successor; the resample-and-cancel stand-in the prior plan carried is retired unbuilt (see Open questions for the contingency). core/instrument/CLAUDE.md's "never wire WDL_Resampler as the duration-preserving path" is honoured by construction.
  • Rate is latched at note-on, delivered by a third commit class: published into the live block like any live parameter, read only by snapLive, never by applyLive. isLiveDeckParam/liveCommitFor widens from two states to three (Live / NoteOnLatched / Reload) in that one predicate — not a second table, and not the reload tier (a swept knob must never trigger a WAV re-decode). Record the reason in the header: loop resolution and contour mapping are note-on folds, so live rate means re-folding a resolved loop and re-mapping a contour mid-note.
  • Pitch is live — under Varispeed one more factor in a multiply the pitch envelope already performs; under Preserve an addend to a shift the pitch envelope already modulates.
  • Loop points scale with rate; contours scale with rate. Neither rewrites stored values: the loop is source-frame facts traversed at the new increment (Varispeed) or the new read rate (Preserve), and a contour is a function of normalized position. Staged envelope stage times do NOT scale — 30 ms is 30 ms at any rate. That asymmetry is deliberate: a contour is of the sample, a staged envelope is of the performance.
  • Deck descriptor: three cells; captionWidth 70, hard ceiling 80 (above that the caption row overtakes the 180 px knob row and the group exceeds 192). If the text will not fit at 80, narrow the Varisp|Presrv segments 48 → 44 (ceiling becomes 88) — do not widen the group.
  • The payload rung appends both fields as a strict suffix; the preceding version's blob lifts to rate 100 % / pitch 0 st, bit-identical playback.
  • Both new DeckParams are classified in the three-state predicate, and that classification is what puts them in the VST3 parameter list three waves later — Rate NoteOnLatched, Pitch Live. Γ-W4-T1 derives the exposed set from this predicate rather than from a list of its own, so a mis-classification here is a mis-declared parameter there.

Acceptance criteria.

  • Rate at 50 % plays an octave down and half speed under Varispeed; at 200 %, an octave up and double speed. Under Preserve the same settings change duration only — pitch is unchanged within the stretcher's tolerance. Preserve Rate is a finished feature the day this lands, because Γ-W1-T5 already shipped its engine; a degraded or inert Preserve Rate is a failed track, not an acceptable interim.
  • Rate at exactly 100 % and Pitch at exactly 0 st render bit-identical to the pre-change build, in both engines.
  • Shift-drag on Rate lands on whole semitones (so an octave and a fifth are reachable by hand); Shift-drag on Pitch lands on whole semitones; Ctrl gives cents on both.
  • A Rate change while a note sounds does not alter that note; the next note-on takes it. It does not trigger a reload or an engine rebuild — assert the tier, not just the sound.
  • A Pitch change does move a sounding note, in both engines.
  • With a loop set, changing Rate changes the loop's audible period without moving either waveform marker.
  • The PITCH/RATE group measures exactly 192 px; adding the two DeckParams produces a compile error in isLiveDeckParam's exhaustive switch until they are classified.
  • A project saved at the preceding payload version reopens at rate 100 % / pitch 0 st and sounds identical.

Open questions.

  • None [Daniel].
  • [propose at review] Rate's clamp behaviour at the range extremes as it meets the stretcher's own ratio bounds — one clamp, resolved where the two meet, not two that can disagree.
  • Named contingency, not a plan item, and not to be taken silently. If Γ-W1-T5's measure-and-report gate has not passed when this track is ready to dispatch, the pre-agreed fallback is the resample-and-cancel composition spec §2.5 records — a resampled read with the resulting pitch change cancelled in the existing SOLA shifter — shipped as an interim Preserve path with the stretcher as its later quality upgrade, i.e. a return to the prior four-wave order. Escalate to Daniel rather than taking it: it revives a disposable implementation and re-opens the WDL_Resampler guardrail conversation, and the whole point of the resequencing was to avoid building it.

Γ-W2-T2 — loop-crossfade-ux

Goal. Give the loop an explicit enable, make the loop and crossfade marks legible, and paint the crossfade where it is actually heard.

Spec: docs/product/instrument-control-surface.md §6 — read §6.1 (the diagnosis), §6.4 (the enable) and §6.5 (trade-offs) in full before starting. This is the phase's one genuinely designed surface; the sections are the brief.

Surface boundary — owns: shell/instrument/editor_paint_waveform.cpp's marker/loop draw, shell/instrument/editor_input_waveform.cpp's marker hit-test routing, core/instrument/ui/waveform_view (cap rects, label boxes, the label-suppression rule — all pure, all CTest-covered), and — new, from the Γ-F4 rulingcore/instrument/ui/sample_chrome (the enable's rect in the toolbar control run), shell/instrument/editor_paint_chrome + editor_input_chrome (its draw and hit-test), and shell/instrument/editor_session.cpp's pickedMarkers / applyMarkers only (the retention rule — see the wave header for the shared-file partition). Does not own loop_span, the crossfade model, any parameter, or any ComponentState version. This track changes drawing, hit-testing and one editor-state retention rule — no format change.

Behavior.

  • An explicit loop enable on the CHROME ROW (Γ-F4, ruled). A two-segment Loop Off|On toggle joins the toolbar row's right-anchored control run, immediately left of the Mono|Stereo toggle, with Browse still rightmost. Loop is a waveform-overlay concept and has no deck — a deck cell was never the right home. Because the run is right-anchored and the title slot absorbs it, this costs zero window width and none of the 90 px headroom; if the title will not hold its text at the 1190 floor, the enable's segments narrow — the floor does not move.
  • The enable IS SampleLoop::hasLoop. No new field, no version bump. The field already exists (play_params.h:210), is already what resolveLoop refuses on (loop_span.cpp:12), and is already persisted in the payload's loopOverride block — where start/end are written unconditionally whatever hasLoop says (params_payload.cpp:31-36), so the wire can already carry "off, with a span remembered." What changes is the field's provenance: today it is derived from the marker gesture, and after this track it is user-owned, with the gestures as shortcuts onto it.
  • Collapse-to-off survives as a shortcut, not as a second state machine. hasLoop is the single authority; four gestures reach it:
    Gesture Enable Span Crossfade
    Enable → On on retained retained
    Enable → Off off retained retained
    Collapse the span onto itself off destroyed, re-parked at defaultLoopBounds zeroed
    Drag either loop mark while off on takes the drag retained, re-clamped
  • Two consequent behaviour changes, each with its reason. (a) pickedMarkers' re-park (editor_session.cpp:221-226) currently triggers on !hasLoop; it must become conditional on the span being invalid (collapsed / inverted / out of range) rather than on the enable being off — a toggle whose off→on does not restore what was there is a delete button, not a toggle. (b) applyMarkers' crossfade zeroing (:240-243) moves from "the enable is off" to "the span was destroyed." The original reasoning is preserved, not overruled: it zeroes so a stale length cannot silently re-apply against a span that no longer exists; with the span retained, its clamp bound is retained too and there is nothing stale.
  • The "drag me" affordance splits into two off-states. Off with no span ever set — pair parked at defaultLoopBounds, Disabled, caption DRAG TO SET LOOP. Off with a span retained — pair Disabled at its own positions, caption LOOP OFF (there is nothing to "set"). In both, dragging a mark turns the enable on — the shipped drag-to-create gesture survives and now teaches the enable by demonstration.
  • In Trigger the enable draws Disabled and inert, and does NOT clear hasLoop — Disabled-not-hidden, the same grammar as the marks, with its state restored on the return to Gate. This transitively covers the drawn-EG case via enforceGateUnavailableWhileDrawn (play_params.h:198-205), which forces Trigger whenever an envelope is drawn — one predicate, not a second rule. Disabled-but-grabbable (the off marks) vs. Disabled-and-inert (Trigger) is deliberate: the user's own off is reversible by the very gesture on offer; Trigger's refusal comes from the engine and no drag can talk it out of it.
  • One mark grammar: line + shaped cap + label. The cap IS the grip. Four marks: START (accent/primary, solid right-pointing triangle cap, solid line — the only primary-ink mark, because it is the only one always in effect); LOOP (accent/secondary, L-cap opening right); END (accent/secondary, L-cap opening left); XFADE (accent/secondary reduced alpha, ramp cap, dashed line — a soft boundary). This replaces the bare 10 px orphan tab that today marks the crossfade with no line of its own.
  • Labels in Font::Micro/TextDim, drawn beneath the trace and handles in z-order. A mark's label re-draws on top on hover or drag of that mark. A label is suppressed if its box would overlap one already placed; placement order is grabbed/hovered first, then START, LOOP, END, XFADE. Occlusion by an envelope node is accepted and named — the cap shape carries the identity permanently, the label is for learning.
  • The crossfade moves to [loopEnd crossfade, loopEnd) — where it is audible. The handle moves to the loop-end side; drag direction is unchanged (left lengthens), so the muscle memory survives.
  • The crossfade region draws as a top-and-bottom edge wedge, NEVER as a second fill. A triangular band at the overlay's top and bottom edges growing from zero at loopEnd crossfade to ~10 px at loopEnd. This is a hard constraint: the region is now inside the loop span, where a translucent fill would stack on the 0.20 loop fill, and the envelope trace crossing that fill is a known, accepted under-floor pair at 2.25:1 (editor_paint_waveform.cpp:28-34), whose own note says the FILL is what changes if it is ever resolved. The loop fill's peak alpha must stay exactly 0.20.
  • The ingredient draws as a ghost. [loopStart crossfade, loopStart) draws the mirror wedge at half alpha, no handle — a hairline dashed outline at rest, filling in on hover or drag of the crossfade handle. This makes the crossfade ≤ min(start, loopLength) clamp self-explanatory: the fade stops growing exactly when the ghost's left edge reaches START or LOOP, so the user sees the reason instead of hitting an invisible wall.
  • Trigger mode: the loop pair and the crossfade mark draw Disabled and are not grabbable, with a dim LOOP — GATE ONLY caption — Disabled rather than hidden, matching the editor's existing Gate-segment grammar, and because hiding a set loop on a mode flip destroys information the user put there. START stays fully live.

Acceptance criteria.

  • The four marks are distinguishable by ink and cap shape with the labels suppressed, and named when they are not.
  • The shaded crossfade region sits over the frames where the fade is audible — verify against a rendered loop, not by reading the code.
  • Every mark is grabbable by its cap; grabbing a mark shows its label.
  • The crossfade at its clamp shows the ghost's left edge coincident with the bounding mark.
  • The loop fill's peak alpha is unchanged at 0.20 and the accepted 2.25:1 trace pair is neither improved nor worsened.
  • In Trigger, no loop mark accepts a grab, the chrome enable is Disabled and inert, and the reason is on screen. Returning to Gate restores the enable's prior state.
  • Turning the enable off and on again restores the loop exactly — same span, same crossfade, no re-park. Collapsing the span instead turns it off, re-parks at defaultLoopBounds and zeroes the crossfade. Both paths asserted.
  • Dragging a loop mark while the enable is off turns it on, in both off-states.
  • The enable costs no window width: kEditorMinWidth is unchanged by this track, asserted by the same derived test that guards the floor.
  • No ComponentState version moves; no new persisted field; resolveLoop is untouched; audio is unchanged. The enable round-trips save/reload through the existing loopOverride block, in both states, with the span retained across an off.
  • All cap/label/suppression geometry is pure and unit-tested; no hit-test math in the painter. The enable's rect lands in sample_chrome alongside the rest of the control run.

Open questions.

  • [propose at review, then verify by hand] The claim-arbitration inputs change: markerHandleRect today gives a tab to the crossfade only, and resolveWaveformClaim breaks ties by smallest nominal target area. Giving every mark a cap-grip changes the candidate set and every nominal area in it. The arbitration must be re-derived, and docs/TODO.md's open entry "Pre-existing staged-envelope-node shadow at zero-attack" must be re-evaluated against the new cap geometry and its outcome recorded — resolved or worsened, either is acceptable, silence is not.
  • No [Daniel] questions. Fork Γ-F4 is ruled — there is an explicit enable and it is on the chrome row, in this track. The prior framing ("an enable needs a cell, so it is a Γ-W3 layout decision") was wrong and is retired: loop has no deck, so it never needed one.
  • [propose at review] every site that currently infers hasLoop — two in editor_input_waveform (:255, :258), two in editor_session (:222, :236) — is now writing to a user-visible control rather than to an internal flag. Re-read each in that light; "it still compiles" is not a disposition.
  • Named escalation, not a fallback to take silently: if the top strip reads crowded in the DAW, the pre-designed answer is the marker rail (spec §6.2, Direction 2) — a larger build that would also dissolve the arbitration problem structurally. Escalate; do not improvise a half-rail.

Γ-W3 — The reflow, and the bake correction

Depends on Γ-W2 for: the PITCH/RATE descriptor (W2-T1) — the reflow measures the real three-cell group, and laying it out against a forecast of that group means re-measuring afterward. This is the whole reason the arrangement is late, and it is why the canvas was split out of it into W1-T4. T2 depends on the same wave for a different reason: rate and pitch offset must exist before the bake's reset list can name them.

Depends on Γ-W1 for: W1-T2's published meter/GR/clip state, which MASTER's deck draws (drawing against a stub would mean building the meter twice), and W1-T4's floor, budget constants and row predicate, which T1 consumes rather than re-derives. T2 depends on W1-T2 for the limiter enable flag, the third of the three values it must add.

Depends on Phase Ξ for T2 — the phase's only EXTERNAL gate. Ξ-W2-T1 (resample-bake-chain) must have landed on dev before T2 dispatches. T2 amends what that track shipped; it cannot amend a branch.

Two tracks, disjoint by surface — but T2's disjointness is CONDITIONAL and must be confirmed, not assumed. T1 owns ui/knob_deck, ui/deck_groups (row-predicate consumption, FILTER's caption move, MASTER's inventory) and shell/instrument/editor_paint_deck. T2 owns the bake's reset step wherever Ξ-W2-T1 put it. T2's first act is to read what actually shipped and confirm its reset surface touches none of T1's three modules. If the shipped reset enumerates controls through deck_groups or deck_values, the two are not disjoint and T2 serializes behind T1 inside the wave — a named contingency, taken openly, not discovered at merge. That risk is real precisely because this plan cannot predict the shipped shape; predicting it is what put the phase in this position.

Why T1 is one track. The row law, the group inventory and the double-height deck are one geometry decision spread over knob_deck, deck_groups and the deck painter. Splitting it would put two tracks in the same pure modules. The window floor is no longer part of it — W1-T4 set it two waves ago, and this track must not move it.

Neither track takes a payload rung. T1 is layout only; T2 changes a reset list, not a format.

Γ-W3-T1 — deck-reflow

Goal. Two categorical rows plus a double-height MASTER bus deck, inside a 1280 × 720 ceiling, returning 112 px to the waveform.

Spec: docs/product/instrument-control-surface.md §1 (the whole section, incl. the §1.2 measured table and §1.6, the headroom ledger) and §3.23.3 (what MASTER draws). §7 lists the invariants this track invalidates or widens — read it before touching knob_deck.h.

Surface boundary — owns: core/instrument/ui/knob_deck (the row law, the double-height group, the justification — consuming W1-T4's budget constants, not restating them), core/instrument/ui/deck_groups (consumption of W1-T4's row predicate, FILTER's Band|Notch caption move, MASTER's inventory), and shell/instrument/editor_paint_deck (the MASTER meter/limiter/bubble draw). Does not own kEditorMinWidth or any budget constant — those are W1-T4's and are read, never moved — nor any parameter, the limiter DSP, or the waveform band.

Behavior.

  • Row 1 (sound), one row, non-negotiable: PITCH/RATE 192 · FILTER 432 · VELOCITY 192 · VOICE 164 = 980 natural.
  • Row 2 (contour): PITCH ENV 252 · FILTER ENV 312 · AMP ENVELOPE 312 = 876 natural.
  • MASTER is double-height (216 px) and right-anchored, outside both rows, 142 px wide.
  • FILTER's Band|Notch moves from its row-toggle position to the caption corner, taking the group 524 → 432 (92 px). It occupies FILTER's currently-unused captionToggle2 slot — no new geometry is required.
  • VOICE keeps its Retrig|Legato row toggle. Moving it to the caption makes VOICE wider (226, not narrower), because its caption row is the binding side. Verified; do not "fix" it.
  • Justification law, applied to BOTH rows: space-between within the row block; slack divided equally among the row's (n1) gutters, integer residue to the leftmost; no gutter narrower than kDeckGroupGap (12). Decks are never stretched. MASTER is not part of either row's justification.
  • Row block = 1020 px at the floor, giving row 1 gutters 12/14/14 and row 2 gutters 72/72, at which width FILTER's right edge and FILTER ENV's right edge both land on x = 636. That tie-line, row 2's equal gutters, and row 1's minimum gutter being exactly kDeckGroupGap all hold at 1020 and only at 1020 — this is why the floor is 1190 and not 1186. Above the floor the tie-line drifts and that is accepted (spec §1.3).
  • The floor is already 1190 × 680 and the bands are already 216 / 358 — Γ-W1-T4 landed all four in wave 1, and the greedy wrap happened to reach two rows at that width. This track changes none of those numbers; it makes them true by construction instead of by coincidence. Row 1's natural width fits the block only after this track's Band|Notch move: 1030 today, +42 from W2-T1's PITCH/RATE, 92 here, = 980. That is this track's fit assertion and W1-T4 deliberately left it open.
  • The 90 px of remaining headroom is the budget for the life of this layout, and one deck cell is 60 px. This is why MASTER's reserved slot is ONE cell (Γ-F5, ruled): two would spend 60 of the 90 up front on a control nobody has named, leaving 30 — which would freeze row 1 forever, since any later row-1 addition needs 60. Widening MASTER later costs the same 60 it would cost now, and by then the trade is against a real control instead of a guess. State this ledger where a future reader will hit it — spec §1.6 is its home, and a reader proposing a new knob needs to see it before they propose.
  • MASTER's interior (spec §1.4, exact to the pixel): caption row with the limiter toggle and a round 12 px warn GR bubble in the far corner (non-interactive — the same slot the envelope decks' radio uses; round so it reads as a lamp, not a control); gain knob in the upper-left cell at box-relative y = 26 and a reserved empty slot at y = 138 — i.e. the two cells land on row 1's and row 2's knob baselines exactly, which is what stitches the spanning deck to both rows; meter column 62 px wide × 186 px tall on the right.
  • Three rules not to generalise wrongly: MASTER's left column uses fixed cell slots at the two baselines, NOT the horizontal run-division law (that law would stretch one knob over 186 px); the reserved slot draws nothing (blank reads as breathing room, a dashed placeholder reads as unfinished); the meter is one rect spanning both baselines, not two per-row meters.
  • The meter draws W1-T2's published state, with the ballistics run on the UI timer. Bar count follows the same LaneSplit decision waveformSurface already folds (channel mode ∧ source channel count) — one wide bar when the waveform draws one lane, two skinnier bars when it draws two. Not a second rule: a mono source in stereo mode is dual-mono, and two identical bars would be a lie.
  • Meter appearance: bar in accent/primary; peak-hold tick 2 px in text/primary; clip cap in warn, latched, click-to-clear; scale linear in dB over 60…+6 with ticks every 6 dB and numerals at 0/12/24/36/48/60, the 0 dB tick heavier. No green/yellow/red segmentationwarn stays reserved for clip states.

Acceptance criteria.

  • At the floor width the deck lays out in exactly two rows plus the spanning MASTER, by construction — asserted against the group inventory, not observed as a wrap outcome.
  • Every group's width matches the §1.2 table exactly, in both Gate and Trigger (row 2's natural width is mode-stable at 876 because the reserve slots hold FILTER ENV and AMP at 312 in both modes — assert it).
  • Row 1 and row 2 are flush left and flush right; at the floor width the filter tie-line is exact (both edges at x = 636) and row 2's two gutters are equal.
  • Row 1's natural width is 980 and fits the 1020 block — the fit Γ-W1-T4 could not yet assert, closed here by the Band|Notch move.
  • kEditorMinWidth is still 1190 and the floor is still ≤ 1280 × 720 — unchanged by this track, verified against Γ-W1-T4's derived test rather than a second copy of it.
  • The waveform band is 358 px at the floor, and the deck band is 216 — unchanged from the interim, now reached by construction: deckRowCount at and above the floor is 2 because the row predicate says so, not because a wrap landed there. Assert against the group inventory.
  • MASTER's gain knob shares a knob baseline with FILTER's knobs; its reserved slot shares one with AMP ENVELOPE's.
  • The meter reads correctly in mono and stereo, the peak-hold tick holds 1.5 s, the clip cap latches and clears, and the GR bubble lights only while the limiter reduces gain.
  • With the limiter engaged the clip cap never latches on material the limiter is catching; if it does, that is a defect report against W1-T2, not a user error.
  • knob_deck's and sample_bands' tests are updated to the new law, and the invalidated notes in knob_deck.h (the fourteen-pixel headroom figure; the cells-and-floor pairing) are re-derived, not deleted — spec §7.1, §7.4.

Open questions.

  • [propose at review] Whether the greedy whole-group wrap survives at all as a sub-floor degrade, or is replaced outright by explicit row assignment. What is not optional: at and above the floor width the layout is the specified arrangement, reached by construction. DeckLayout::rowCount/::height change meaning either way (spec §7.3).
  • No [Daniel] questions. Forks Γ-F5 (one cell) and Γ-F1 (680 stays) are both ruled; they are stated in Behavior above, not carried here as options.
  • [verify] deck_groups.cpp's kEnvModeSegW = 23 ceiling rises to 47 once PITCH ENV is on row 2 (AMP binds at 55). No change is required; the comment stating the old ceiling stops being true and must be corrected (spec §7.2).

Γ-W3-T2 — bake-reset-amendment

Goal. Complete the resample bake's reset list against the control surface that now exists — the correction Phase Γ owes Phase Ξ because Ξ-W2-T1 shipped ahead of the sequencing this plan asserted.

Consolidates: nothing from the seventeen. It is a correction obligation, not a feature (see "Flagged for awareness" item 2).

Spec: docs/product/instrument-control-surface.md §3.4, and Ξ-W2-T1's own "Reset scope" block above — which is the ratified rule this track applies, not a rule it may reinterpret.

Surface boundary — owns: the bake's parameter-reset step, wherever Ξ-W2-T1 landed it, and its tests. Does not own the bake chain, the crossing architecture, the replace-vs-add decision, the capture path, any deck module, any painter, any parameter, or any ComponentState version. It changes what a shipped list contains — nothing else.

Behavior.

  • Three values join the reset list, all classified against Ξ-W2's own ratified rule ("reset what the bake baked in"), all reset, none of them a new Daniel decision: rate, pitch offset, and limiter enabled. The limiter's reasoning is worth carrying rather than re-deriving: master gain is already on the reset list, so the bake includes the master stage, so the limiter's effect is in the audio.
  • Verified against what shipped, not against what was predicted. This plan named three values before either the bake or the controls existed. Read Ξ-W2-T1's landed reset list first and reconcile: if it already anticipated any of the three, say so and drop it; if it classified something differently from docs/product/instrument-control-surface.md §3.4, the landed code is the fact and this plan is the prediction — escalate the difference, do not silently overwrite either.
  • Re-run Ξ-W2-T1's own "genuinely new parameter" check over everything Phase Γ added, not just the three named. Γ also ships the loop enable (W2-T2) and raises the stage-time ceiling (W1-T1). Classify each against the rule: the loop enable is a loop fact whose effect is in the rendered audio (reset, with the loop points it travels with); the ceiling is not a parameter at all. State each disposition; silence is not one.
  • Root note still survives. The bake's most load-bearing exception is untouched by this track — capturing at root is what makes root survivable, and resetting it would detune every subsequent iteration.

Acceptance criteria.

  • After a bake, rate reads 100 %, pitch offset 0 st, and the limiter reads bypassed — and the root note, key-tracking and the VOICE group are still untouched.
  • The bake stays audible and faithful with the new controls dialled in: dial rate, pitch offset and the limiter, bake, and the neutral instrument playing the programmed note sounds as the dialled one did — the criterion Ξ-W2-T1 already carries, now actually exercised over Γ's controls.
  • A reconciliation note in the track's review stating, per value, whether the landed code already covered it, and recording any difference between what shipped and what §3.4 predicted.
  • No format change, no new field, no version bump, no change to the crossing architecture or the replace-vs-add decision. A regression baseline proves the bake's audio is otherwise unchanged.

Open questions.

  • No [Daniel] questions. The rule is ratified and §3.4's classification is derived from it.
  • [verify, FIRST] the disjointness contingency in the wave header: read the shipped reset step and confirm it touches none of Γ-W3-T1's modules. If it does, serialize behind T1 and say so.
  • Explicitly NOT this track's: the two consequences automation adds to the bake — the reset having to notify the host, and a host lane re-imposing its curve onto baked audio. Both are Γ-W4-T1's, because that track creates them. Doing this correction once, before automation, and letting Γ-W4-T1 add its own obligation on top is deliberate: the alternative is an amendment to an amendment.

Γ-W4 — VST3 parameters

Depends on every earlier wave, and each dependency is a hard prerequisite rather than a courtesy:

  1. ← W1-T1. The taper and the 10 s ceiling are the host-facing normalization, and W1-T1 is also what extracts them into the one pure module the host reads through. Declaring parameters against a taper that is still moving is the one-way door this whole phase is ordered around.
  2. ← W1-T2 and W2-T1. Every control that could be a parameter must exist before the list is declared. The list is derived from the control inventory; an inventory still growing produces a list that has to be re-frozen, and it cannot be.
  3. ← W2-T1 specifically. isLiveDeckParam becoming three-valued is the prerequisite of the classification, not an incidental: the exposed set is exactly Live NoteOnLatched.
  4. ← W3-T1. MASTER's inventory (limiter toggle, GR bubble, reserved cell) is the last change to what controls exist at all.
  5. ← W3-T2. The bake's reset list must already be complete, so this track adds the host-notification obligation once rather than amending an amendment.

One track. The storage decision governs every part of the work — the projection rule, the migration path, what getParamNormalized returns, and what the bake's reset must do — exactly as Ξ-W2-T1's crossing decision governs its chain. Every candidate split (a pure model/classification half and a host-wiring half) is serial, so it buys no concurrency and puts the decision on one side of a boundary and its consequences on the other.

Γ-W4-T1 — vst3-parameter-set

Goal. The instrument reports its automatable parameters to the host, under a frozen id contract and a logical order — which also hands it REAPER's whole per-parameter modulation block (LFO, envelope follower, MIDI link, parameter linking) for free.

Consolidates: nothing from the seventeen. Ruling 1 (Daniel, 2026-08-01): "correct the phase gamma plan to account for complying with the VST3 standard for parameter reporting… by the end of gamma we have the automatable params reported. Make the parameter order logical."

Spec: docs/product/parameter-automation.md§§610 are the specification; §§15 are the analysis behind it. Read §6.1 (storage), §6.3 (the freeze), §7 (the classification) and §8 (the one-way-door sweep) before scoping. Today the plugin has zero parameters: ReaSamplerProcessor::initialize (reasampler_processor.cpp:56-73) never populates SingleComponentEffect::parameters, so getParameterCount() returns the SDK default 0. This track introduces the whole surface.

Surface boundary — owns: a new pure parameter-identity module (the frozen id table, the DeckParamParamID mapping, the derived exposed set, the unit assignment — with its own <module>_tests target), shell/instrument/reasampler_processor + processor_state (the IEditController parameter surface and the IParameterChanges read), shell/instrument/editor_controls.cpp and the editor's drag-commit sites (the beginEdit/performEdit/endEdit bracketing), and the bake's reset step for the notification path only. Does not own the taper (W1-T1's module, consumed), any control's value semantics, any deck geometry, or the bake's reset membership (W3-T2's).

Behavior.

  • The blob stays authoritative; a parameter is a THIRD SURFACE onto the one model — a peer of the deck knob and the overlay node, not a second copy of the value. docs/product/parameter-automation.md §6.1 states the load, host→plugin, plugin→host and save rules, and the two verified findings that closed the fork: this plugin is a SingleComponentEffect, where the SDK itself collapses IComponent::setState and IEditController::setState (vstsinglecomponenteffect.h:41-47), so there is one state and §3.3's drift hazard describes a split-component design we do not use; and the blob is a cross-artifact contract the extension's instrument_drop writes, which parameters-as-truth would silently make partial.
  • ParamID is an independent, hand-assigned, FOREVER-FROZEN table — blocks of 100 per deck group in signal-flow order (Γ-F7), steps of 10 within a block, a curve dial at its outer knob's id + 1, blocks starting at 1000. The full 44-id assignment is stated at §6.2 and is to be transcribed, not re-derived. §6.2 also for why hand-assignment beats derivation and why the within-block order is seeded ONCE rather than tracked against cellIds; §6.3 for the freeze invariant, which is to be stated in the table's header with the same force as the command-id strings, the class UIDs and the payload field order.
  • The exposed set is DERIVED from isLiveDeckParam / liveCommitFor, never hand-maintained — a control is a parameter iff its class is Live or NoteOnLatched. 44 parameters at the end of Γ-W3, enumerated by group in §7.1.
  • Everything else is OMITTED from the list entirely, not exposed-and-flagged: the reload and rebuild tiers, all structural state, and the limiter enable (§3.8, settled). §7.2 states why omission beats kIsReadOnly, and names the limitation plainly — the user cannot automate filter on/off, play mode, the pitch engine, Staged↔Spline or polyphony, and the unlock is to give the control a live path first.
  • Every exposed parameter reports REAL UNITS to the host (Ruling 3). Each declares a plain range, a units string, and a display precision; the complete eight-category table covering all 44 — ranges, units, precision, and which taper each category carries — is docs/product/parameter-automation.md §6.7.1, and it is a specification, not a suggestion. VST3's wire format stays normalized (it cannot be otherwise); the requirement is met through the plain-value layer the SDK provides, whose direct precedent in the vendored tree is public.sdk/samples/vst/common/logscale.h:221-229 overriding toPlain/toNormalized for a log law — the exact shape our log ms and log2 semitone knobs need.
  • normalizedParamToPlain / plainParamToNormalized / getParamStringByValue / getParamValueByString route through W1-T1's taper module and the ONE formatter per unit category. Three functions that agree today is a defect; the host's normalization, the needle angle and the overlay node must be the same function. toNormalized IS the taper — §6.7.3 — which is what makes the phase's one-way-door ordering structurally true rather than a warning someone has to remember.
  • stepCount = 0 on all 44, and the editor's shift-snap is never exposed as stepCount. Snapped drag and parameter continuity are independent axes; stepCount quantizes the parameter permanently, including for the host's automation, and freezes into the forever contract. The sweep is done and clean (§6.7.6) — every discrete control is reload or rebuild tier and therefore already omitted, so continuity is structural rather than lucky.
  • IParameterChanges is observed at BLOCK boundaries, stated in the header — the last point in a block wins. Sample-accurate application would put a per-sample "did anything change" question on the per-voice-per-sample path, which the phase-wide guardrail forbids.
  • A host parameter change takes the control's existing commit tier and no other. Nothing on the automation path may reach reloadInstrument or rebuildVoiceEngine — which §7.2's omissions guarantee structurally rather than by care.
  • IUnitInfo: one unit per deck group, mirroring the group inventory rather than the editor's rows. Order is signal flow — Γ-F7, RULED (§6.4). Presentation index order is ascending id, so identity and presentation agree by construction.
  • The filter's four report plain units WITHOUT being re-tapered. toPlain is read-side only; reporting Hz / Q / drive depth means calling filterCutoffHzFromNorm, filterQFromNorm and filterDriveDepthFromNorm — the filter module's own frozen laws, which deckValueLabel already calls today — not restating them. The one additive piece: filterNormFromDriveDepth does not exist and must be added in filter_params, beside the two inverses that do; the analytic inverse of a frozen law is not a change to it. §6.7.5.
  • No kIsBypass on anything. The plugin is an instrument and exposes no bypass parameter; the limiter is a safety device, not a bypass, and binding it there would hand the host a control that restarts the component.
  • The bake's reset gains a notification obligation (§9): every internal writer of a value that is an exposed parameter must go through the one beginEdit/performEdit/endEdit path, and the bake's reset is the codebase's first non-gesture writer. Enumerating those sites is part of this track, not a follow-up.
  • Two adjacent SDK surfaces are assessed, with dispositions, so they are not re-surveyed: IMidiMapping is in scope and nearly free (a CC → ParamID map, one function); IParameterFunctionName is not implemented (its vocabulary is compressor/panner semantics that name nothing here); IAutomationState is not implemented (it reports the host's automation mode for the whole plug-in, not per parameter, so it cannot answer the one question §9 would have wanted it for).

Acceptance criteria.

  • The host lists exactly the derived set, in the ruled order, with no parameter the predicate does not classify Live or NoteOnLatched — asserted against the predicate, not against a literal count.
  • Every id in the table is asserted unique, in its group's block, and on its step — and a test fails if any id changes value, which is what makes the freeze mechanical rather than cultural. The asserted values are §6.2's table verbatim, including the signal-flow block sequence; a test that recomputes the ids from cellIds would defeat the freeze it exists to hold.
  • toPlain(info.defaultNormalizedValue) compares EXACTLY equal to the default, per parameter, against a default-constructed PlaySeconds (and against master_gain's unity), so a host's reset-to-default and the editor's double-click land on the same value. defaultNormalizedValue is computed as toNormalized(default), not written as a literal — a grep finds no normalized default constant. If the exactness fails, it is a W1-T1 defect surfacing here, not a defect of this track. Round-trip exactness at arbitrary values is NOT asserted — it is not required (§6.7.7) and asserting it would over-constrain the taper.
  • getParamStringByValue prints what the editor's knob label prints, digit for digit, at the same stored value, across every unit category — asserted by calling the same formatter from both sides in one test, not by comparing two independently produced strings. A grep finds exactly one formatter per unit category and no snprintf of a parameter value outside it.
  • Every exposed parameter's units and plain range match §6.7.1, asserted per parameter; stepCount is asserted zero on all 44.
  • A host automation lane moving a Live parameter moves a sounding note; a lane moving a NoteOnLatched parameter takes effect on the next note and does NOT trigger a reload or an engine rebuild — assert the tier, not just the sound.
  • No automation path reaches reloadInstrument or rebuildVoiceEngine.
  • A project saved before this change opens with every parameter reading the blob's value and sounds identical; a project saved by this build opens in an older binary with its sound intact; and a project with automation drawn, saved and reopened, replays against the same plain values.
  • process() takes no new indirection and no new per-sample work — the parameter read is a block-boundary act, on the existing live-publish path.
  • The bake's reset notifies the host, verified by the host's displayed value following it rather than snapping back on next touch.
  • The double-processing limitation is documented, not discovered — a bake whose reset-class parameter carries a host lane is a named boundary of the bake's fidelity claim (§9), stated in the product doc and in this track's review.
  • The lane-linearity consequence is stated in the header, not left to be found — under a tapered parameter a straight line drawn in a host automation lane is not linear in the plain unit (exponential in ms, linear in octaves on cutoff, linear in dB on master gain). This is standard and desirable, it follows directly from Ruling 3 plus the taper, and §6.7.4 gives it per category. Writing it down is the acceptance criterion; changing the taper to avoid it is not an option.
  • The filter's four are proven untouched: a regression baseline shows their audio unchanged, and their persisted *Norm values are byte-identical across a save/reload that passes through the parameter surface. Reporting Hz/Q/depth changed display only.

Open questions.

  • No [Daniel] questions. Γ-F7 is RULED — signal flow (2026-08-01, "signal flow order."), and Ruling 3 (real units) arrived specified rather than forked. There is no unanswered [Daniel]-class question in this track or anywhere in this plan.
  • [verify, FIRST] that REAPER calls setState (not setComponentState) on a single-component plug-in, and the ordering of setState against the first IParameterChanges block after a project load. §6.1 is built on the SDK's own name-collapse; verify it in the DAW before wiring, and do not build on the paragraph alone.
  • [verify] whether REAPER renders ParameterInfo::units beside the string getParamStringByValue returns, or shows the string alone. We ship the SDK's own convention — digits in the string, unit carried separately, which is what RangeParameter::toString and the Parameter constructor's signature both express. If REAPER shows no unit at all, the fallback is to append the unit inside the one formatter: a one-line change in one place, touching neither the frozen id table nor the editor, because display strings are explicitly not frozen (§6.7.1). Do not discover this after shipping.
  • [propose at review] promoting key-track and Trigger length from Reload to NoteOnLatched (§7.4). Both are excluded from the live set for the note-on-latch reason in the predicate's own words, so the promotion aligns routing with documented semantics — and it is what makes them automatable at all. If either promotion is refused, that control simply drops out of the parameter list. The list follows the predicate; the predicate is never bent to fill the list. Consequence for the frozen table: a refusal drops ids 1000 and 1260 (key-track) or 1450 (Trigger length) and the count falls below 44. Those slots are then simply never issued — not retired, since nothing shipped under them — and remain available to the same control if it is promoted later. No other id moves; that is what the block-and-step scheme buys.
  • [propose at review] whether to ship a default IMidiMapping CC table here or leave MIDI control to REAPER's host-side learn. Either is defensible; skipping it silently is not.
  • [propose at review] whether this track spends the reserved payload rung. §6.1 says nothing new is persisted and therefore it should not; if the setState verification says otherwise, it takes the reserved rung and says so.
  • Closed, do not reopen: Rate lifted from latched to live (§3.5 records the cost); the limiter enable made automatable (§3.8 — its one reopening condition is the docs/TODO.md reload/activation decoupling, and the answer is to do that first, not to re-litigate the classification).

Phase Ψ — The extension trust pass: exact bounds, disjoint solo surfaces, reachable actions, honest drops, real names, true mono

Ships: capture ranges that mean what was asked — a time selection or razor area over a longer item captures the selection, both scopes; per-mode solo surfaces that cache, clear, and restore across the Design/Arrange switch, with the switch itself refused while the transport runs; the Media-Explorer import action registered into the Media Explorer action section so it can live on that toolbar; a drag-out gesture law that resolves its target from what is actually under the cursor, continuously and reversibly, with no silent no-op release anywhere; captures labeled after their source track instead of the literal "item"/"track", surfaced on the panel card; and lossless mono collapse for new captures whose channels are bit-identical.

Consolidates: none of the seventeen. Phase Ψ came from a direct list of seven defects and refinements (Daniel, 2026-08-01). There is no backing product doc — the design content lives inline in the tracks below, and the seven are recorded here as the phase's provenance, cited throughout as Ψ.1–Ψ.7:

Ψ.1 — item and track captures should be named (labeled) after their source track name, plus a discriminator (date, etc.); currently they aren't named anything useful. Ψ.2 — Design vs. Arrange modes: any SOLO state in one mode is cached and removed when switching to another mode, disjoining the solo surfaces. Ψ.3 — the Design/Arrange active-mode toggle is gated if playback is running; only allow the switch when the project is not playing. Ψ.4 — the action that moves a Media Explorer item to a new ReaSampler on the selected track is not in the Media Explorer action category, so it cannot be added to the Media Explorer toolbar; fix this. Ψ.5 — drag-and-drop targets are inexact: sometimes dropping into the arrange doesn't work, sometimes dropping into the FX area doesn't work; dragging between banks is fine. Ψ.6 — capture into mono: if the left and right channels of a new capture are bit-identical, collapse to mono — one channel of data, mono arrange items, ReaSamplers load in mono mode. Ψ.7 — capture item / capture track with a small time selection on a large item captures the entire item, not the selection/razor; make capture regions consistent and correct.

Where the seven land:

Ψ-item Track Worktree slug
Ψ.7 Ψ-W1-T1 ppsi-w1-t1-capture-range-exactness
Ψ.2 + Ψ.3 Ψ-W1-T2 ppsi-w1-t2-mode-switch-discipline
Ψ.4 Ψ-W1-T3 ppsi-w1-t3-media-explorer-section
Ψ.5 Ψ-W1-T4 ppsi-w1-t4-drop-target-resolution
Ψ.1 Ψ-W2-T1 ppsi-w2-t1-capture-naming
Ψ.6 Ψ-W2-T2 ppsi-w2-t2-mono-collapse

Ψ-W3 is not one of the seven — opened mid-phase, after Ψ-W2's review surfaced that the track scope carried the same multi-track stem-collapse hole Ψ-W1-T1 had just closed for item scope. It consolidates none of the original seven and carries no Ψ-item row above; see Ψ-W3 below.

All three waves have landed — Phase Ψ is complete. W1 and W2 each carry their own landed notes below; W3 carries its own too. See docs/COMPLETED.md for every track's full narrative. None of the seven tracks is DAW-verified — all are code-complete and unit-tested, several resting on a shared unverified inference about how REAPER's selected-tracks render source interacts with custom time bounds, which Ψ-W3-T1's refusal now also rests on; each track's DAW-verification obligation is restated inline below.

Ψ.2 and Ψ.3 share one track deliberately: they share one chokepoint — applyMode, the sole mode mutator (shell/view/view.cpp:380-469) — and one discriminator (targetModeId != model.activeModeId(), the test that distinguishes a real switch from a reapply). Splitting them is two tracks fighting over the same function.

Concurrency with Γ and Ξ. Phase Ψ is extension-side. Γ and Ξ-W3 live in core/instrument/ + shell/instrument/; the surfaces are disjoint with ONE named exception — Ψ-W2-T2 touches shell/instrument/processor_reload.cpp for a stale comment and an index/file consistency check, and that touch is bounded to the minimum in its surface boundary precisely because Γ is live in that directory.

Three invariant amendments were DELIVERABLES of this phase, not asides. Each landed in its owning track, as an acceptance criterion of that track:

  1. The never-touch-solo rule (Ψ-W1-T2): src/shell/view/CLAUDE.md:14-17, src/core/view/CLAUDE.md:10, and docs/product/design-view.md:160-165 + :588-592. Ψ.2 requires writing I_SOLO; the amended form is stated in the track. A track that lands solo writes without the amendment reads as an invariant breach in review.
  2. The action-registration contract (Ψ-W1-T3): root CLAUDE.md §"REAPER extension contract" documents only the main-section 4-step pattern; the second, non-main mechanism (custom_action + hookcommand2 + -custom_action mirror) must be added beside it.
  3. The channel-count-preserved rule (Ψ-W2-T2): root CLAUDE.md:208 — "channel count preserved (no silent stereo fold)" — is contradicted in text (not in spirit) by a lossless bit-identical collapse, and was already untrue in the other direction (a mono source renders at RENDER_CHANNELS=2 today). The amended form is stated in the track.

Performance posture. Every Ψ surface is cold — per-capture, per-click, per-mode-switch, per-mouse-move. None of the named hot paths (peaks envelope compute, audition, realtime-capture tick, instrument process()) is touched. Two disciplines carry anyway: the realtime tick's single-pointer-test idle fast path is untouched by Ψ-W1-T1's render work, and Ψ-W1-T4 keeps the inside-client drag path free of SDK hit-tests, exactly as today (panel_drag.cpp:273-274 — "costs nothing on the common internal-drag path").


Ψ-W1 — Exact bounds, disciplined switches, reachable actions, resolved drops

Depends on: nothing in this phase.

Ψ-W1-T1 — capture-range-exactness

Landed — see docs/COMPLETED.md for the full narrative. A ranged item capture now renders exactly the requested window instead of the whole item, by re-sourcing through the selected-tracks render when the item extent does not already print the window. Deviation: the spec named two candidate architectures (re-source vs. render-then- trim); the engineer shipped a conditional form of the re-source candidate — the full-extent case runs literally unchanged code, keeping the byte-identity regression floor structural and the fix cheap to revert if the override inference proves wrong. Also landed: a transient isolation guard (cutting B_MAINSEND on direct folder children, muting receives) so an item capture stays true to item scope, and a post-render frame-count gate (±1 tolerance, tail-None only) that refuses a widened render and retains it outside the bank for diagnosis rather than deleting it. New modules core/capture/render_window, core/capture/track_topology, shell/capture/render_selection, shell/capture/render_isolation. The whole fix rests on the unverified inference that REAPER's selected-tracks render source overrides custom time bounds — Ψ-W3-T1 (below) now also depends on it. DAW-verification obligation, unmet: the four-cell scope × selection-type matrix over a source item substantially longer than the selection (tail None), plus one razor-union case — none of it run in a live REAPER session yet.

Ψ-W1-T2 — mode-switch-discipline

Landed — see docs/COMPLETED.md for the full narrative. Per-mode SOLO surfaces now cache, clear, and restore across a Design/Arrange switch, with the switch itself refused, visibly, while the transport is playing or recording. The never-touch-solo invariant — stated three times (src/shell/view/CLAUDE.md, src/core/view/CLAUDE.md, docs/product/design-view.md) — is amended, in this track, to the snapshot sense of non-destructive: solo is cached per mode on a real switch and restored verbatim, not left untouched absolutely the way B_MUTE and the master track are. Also closed: a pre-existing bug where a footer mode-segment click never persisted view state. New core/view/solo_cache, shell/view/view_solo. DAW-verification obligation, unmet: the disjoint-surface solo matrix, the playback-gated refusal (playing and recording), and the panel-persist case — none run in a live REAPER session yet.

Ψ-W1-T3 — media-explorer-section

Landed — see docs/COMPLETED.md for the full narrative. The Media Explorer import action now registers into REAPER's Media Explorer action section (32063) via custom_action + hookcommand2, alongside its existing Main-section entry so existing keybindings survive — a second FOREVER-STABLE id, INGEST_IMPORT_MEDIA_EXPLORER_MX, minted per channel. Root CLAUDE.md's "REAPER extension contract" is amended, in this track, with the second, non-main registration mechanism beside the original four-step main-section pattern. DAW-verification obligation, unmet: adding the action to the Media Explorer toolbar and firing it from there; confirming the Main-section binding still fires; and confirming unload/reload does not leak a duplicate Media Explorer entry (the -custom_action unload mirror is unconfirmed against the SDK header) — none run in a live REAPER session yet.

Ψ-W1-T4 — drop-target-resolution

Landed — see docs/COMPLETED.md for the full narrative. The drag-out gesture is now a per-move, stateless law: target class resolves from what is under the cursor on every move, every class transition is reversible until release or until the pointer leaves REAPER, and the OS hand-off is reserved for leaving REAPER entirely — the whole TCP/MCP is now an instrument-drop hotspot, and a single-card arrange drop lands a timeline item at the pointer's track and time. New shell/actions/arrange_drop_win. DAW-verification obligation, unmet: the full target-class matrix (single- and multi-card), reversibility across a drag that crosses the arrange en route to an FX window, drag-speed independence, and the narrow-TCP case — none run in a live REAPER session yet.


Ψ-W2 — Names and channels, over the settled render block

Depends on Ψ-W1 — specifically Ψ-W1-T1, which rewrites the capture render-configuration block in shell/capture/capture.cpp and core/capture/render_settings.cpp; both W2 tracks edit adjacent regions of those same TUs, so W2 dispatches only after W1-T1 is on dev. (T2T4 of W1 gate nothing here; the wave boundary is the file collision, not a semantic dependency.)

Ψ-W2-T1 — capture-naming

Landed — see docs/COMPLETED.md for the full narrative. Captures are now named after their source track's name plus a discriminator (<Track> [+N] [#ordinal] MM-DD HHMM) at every interactive mint site, with the name shown on the panel card over a scrim clearing the 4.5:1 contrast floor. New core/capture/capture_name. Recapture, ingest, and the bake deliberately keep their own naming — the bake's naming-and-lineage open question stays Ξ-W1-T1's/Ξ-W2-T1's to close, untouched here. DAW-verification obligation, unmet: capturing from a named track, an unnamed track, and a multi-item selection, and confirming the labels show on the card and in the instrument — none run in a live REAPER session yet.

Ψ-W2-T2 — mono-collapse

Landed — see docs/COMPLETED.md for the full narrative. A capture whose channels are bit-identical now collapses losslessly to one mono channel, written via temp file plus atomic rename, with Sample::channelCount measured off the landed file rather than echoed from the request on every capture path — including realtime, which previously parsed the layout and echoed the request anyway. Root CLAUDE.md:208's channel-count-preserved invariant is amended, in this track, to the landed wording: channel count preserved, except that bit-identical channels may collapse losslessly to mono; a lossy fold remains forbidden. The shell/instrument/processor_reload.cpp touch stayed to the minimum named in its surface boundary (Phase Γ was live in that directory). Open, not closed here: whether bake landings collapse too — [propose], leaning yes, still deferred to be confirmed against what Ξ-W2-T1 actually shipped. DAW-verification obligation, unmet: capturing a dead-center mono source and a true-stereo source, inserting both, loading both into the instrument, and running the null test on the collapsed one (REAPER's mono-item-on-stereo-track summing at unity is the specific thing to confirm) — none run in a live REAPER session yet.


Ψ-W3 — Closing the track-scope stem-collapse hole

Depends on Ψ-W2 for: existing at all — this wave did not exist when the phase was scoped. Daniel opened it after Ψ-W2's review surfaced that the track scope carried the same multi-track stem-collapse hole Ψ-W1-T1 had just closed for item scope.

One track. Consolidates none of the seven — it came from a review finding, not from Ψ.1–Ψ.7.

Ψ-W3-T1 — track-scope-range

Landed — see docs/COMPLETED.md for the full narrative. Any multi-track selected-tracks render now refuses, in both scopes, keyed on the render source rather than the capture scope — closing the hole Ψ-W1-T1 left open for track scope. Realtime deliberately diverges and was left untouched, because it sums correctly. The refusal rests on the same unverified inference Ψ-W1-T1 rests on — that REAPER's selected-tracks render source overrides custom time bounds — so if that inference is wrong, this refusal costs a working capture. docs/verify-track-scope-multitrack.md is a new standalone verification script on this branch, for this track's multi-track refusal specifically. DAW-verification obligation, unmet: confirmed nowhere in a live REAPER session yet.


Phase Ε — The bank package: one file that carries a bank between projects

Ships: a bank exported to a single version-tagged .rsbank file — audio bytes byte-exact, index metadata intact — and imported into another project's bank folder and index, with a compatibility policy that names both directions concretely: an older package in a newer build always imports, a newer package in an older build refuses whole with an actionable message, and neither direction is ever a partial landing.

Consolidates: none of the seventeen. Phase Ε came from a direct request (Daniel, 2026-08-02) and is scoped in docs/product/bank-package.md. Nothing in docs/TODO.md or docs/TODO-1.0.md records export, import, or a package format — a sweep of docs/ for export|package|portable returns only unrelated matches (prune's portable move-to-trash, MIDI-playback prose) — so this phase supersedes nothing and absorbs nothing.

Three forks were opened at framing and all three are RULED (Daniel, 2026-08-02). The rationale for each — including why two went against the framing recommendation — is recorded at docs/product/bank-package.md §"Rulings"; the binding specification for each is in the section of that doc named below.

Fork Ruling Specified in Bound into
Ε-F1 Proprietary container — the hand-rolled RSBK. ZIP via the vendored MiniZip64 (vendor/WDL/WDL/zlib/) and a hand-written stored-only ZIP shape are both rejected and are not to be revisited in this phase §"The container" Ε-W1-T1
Ε-F2 Import always lands as a new bank, with an automatic numeric suffix on a display-name collision — no prompt, no overwrite. Merge-into-existing is out of scope for Phase Ε, not deferred §"Identity and collision on import", incl. the auto-suffix rule Ε-W2-T2
Ε-F3 Refuse the import when the tracking ledger is degraded. No confirm-and-proceed path, no opt-out §"Import under a degraded tracking ledger" Ε-W2-T2

No Ε track is gated on a decision. Every track in this phase is dispatchable as written.

Phase-Ε acceptance criteria

These bind every track in this phase, in addition to the plan-wide set above.

  • Byte-exact round-trip is the phase's trust anchor. Export → import → export yields byte-identical payloads, and the hashBytes digest of every landed file equals the digest recorded at export. Frame count, sample rate, bit depth, and channel count are untouched on both sides. No re-encode anywhere: wav_codec may be called to hash and to read metadata already recorded, never to rebuild, trim, normalize, or collapse. The mono collapse is a capture-path behaviour and must not reach the import path — the same exclusion ingest already carries (root CLAUDE.md, exact-bounds invariant).
  • The format cannot express a path. Manifest entries are bare file names — no directory component, no .., no drive letter, no leading separator — validated on encode and decode. Relative-paths-only becomes structural rather than remembered, and the archive-traversal bug class closes by construction.
  • Import places no timeline item. Capture and placement stay separate acts; import is a capture-shaped act, not a placement one.
  • Import never overwrites and never deletes an existing bank-folder file. The one deletion path is the rollback of files this call wrote that no index ever referenced — the documented carve-out at src/shell/persist/prune_fs.cpp:5-11, which every track touching it must cite, not restate.
  • Export is read-only against the project. No ext-state write, no bumpBankGeneration(), no undo point. Import does the opposite: it bumps the generation (src/shell/persist/session.h:108) so live ReaSampler 9000 instances reload, and batches its index mutation into one Ctrl-Z through persistBankOp.
  • All-or-nothing on both sides. No partial export, no partial import. A truncated .rsbank must never exist on disk (temp file + atomic rename, the Ψ-W2-T2 precedent); a half-imported bank must never exist in the index (rollback).
  • At most one entry's payload in memory at a time, on both paths. The pure codec owns framing and offset arithmetic; the shell owns the stream. A whole-package vector<uint8_t> on either side is a rejected shape, not an optimization opportunity.
  • The pure planners take value inputs, never a handle. The decoded manifest, the destination BankBook, and the set of names present in the bank folder cross the seam as values. No ReaSamplerSession&, no service container, no "pass the thing that has everything" reaches core/package/. If a circular dependency appears during the build, the fix is a service split or a thin interface — not parameter propagation, and not a base class gaining a dependency that grows its subclasses' constructors.
  • Every pure module gets a <module>_tests target that runs without REAPER or a DAW. The whole collision/version rule set is expressible as pure functions over strings and hashes; if a rule can only be tested through the shell, the seam is in the wrong place.

Performance posture. Every surface in this phase is cold — per-gesture, once. None of the named hot paths (peaks envelope compute, audition, realtime-capture tick, instrument process()) is touched by any track here. The one performance fact that is load-bearing is the memory criterion above, and it is stated as a structural constraint rather than a guardrail because exceeding it does not slow the feature down, it makes it fail.

Concurrency with Γ and Λ. Phase Ε is extension-side and lands almost entirely in two new directories (src/core/package/, src/shell/package/) that no other phase touches. Γ lives in core/instrument/ + shell/instrument/; Λ is being specced concurrently and is not read here. The only pre-existing files any Ε track edits are named per track below — core/tracking/origin_ledger (W1-T3, exclusively), the root CMakeLists.txt add_subdirectory list (W1-T1 and W1-T2, one line each), src/app/main.cpp and the panel's bank menu (W2-T1 and W2-T2, one registration line and one menu row each), and core/model/bank_book.{h,cpp} (W2-T2 only — one additive public const member, required by the Ε-F2 auto-suffix rule so the name fold keeps its single home). No Ε track touches core/instrument/, shell/instrument/, or any capture backend.


Ε-W1 — The contract, the filesystem, and the ledger's new kind

Depends on: nothing in this phase. Three tracks, disjoint by directory — the split is by what each track's inputs are, which is why they genuinely parallelize: T1 knows only bytes and structs, T2 knows only paths and bytes, T3 knows only the ledger.

Track Owns
T1 package-format the whole of the new src/core/package/ except export_plan / import_plan (W2's), plus its CMakeLists.txt and CLAUDE.md
T2 package-fs-shell the whole of the new src/shell/package/ except export_bank / import_bank (W2's), plus its CMakeLists.txt and CLAUDE.md
T3 import-origin-kind src/core/tracking/origin_ledger.{h,cpp} and its tests, exclusively

One shared file in the wave, named rather than discovered at merge: the root CMakeLists.txt add_subdirectory list — T1 appends src/core/package, T2 appends src/shell/package. Two append-only lines in one list: textual merge adjacency, not semantic contention. Whichever lands second rebases.

All three tracks are dispatchable now. T1 carries the Ε-F1 ruling — the container is the hand-rolled RSBK, decided, not a candidate T1 chooses among. T2 and T3 never depended on that ruling anyway: T2's API is bytes-in/bytes-out regardless of what those bytes mean, and T3 touches no package code at all.

Ε-W1-T1 — package-format

Goal. The container and its version ladder, entirely pure — the contract every later track consumes, landed once so nothing downstream re-litigates the shape.

Spec: docs/product/bank-package.md §"The container", §"Version tagging", §"What a package carries", §"What a package deliberately does NOT carry", §"Memory".

Surface boundary — owns: new src/core/package/package_format (the magic, the header layout, kPackageFormatVersion, kPackageMinReaderVersion, and classifyPackageVersion(formatVersion, minReader) -> Readable | TooNew | Malformed), new src/core/package/package_manifest (the manifest model + its JSON codec), new src/core/package/bank_package (header encode, prefix decode, entry-layout arithmetic), the directory's CMakeLists.txt and CLAUDE.md, and one appended add_subdirectory line in the root CMakeLists.txt. Does not own: export_plan / import_plan (Ε-W2), anything under shell/, core/model, or core/tracking.

Behavior.

  • The container is the hand-rolled RSBK (Ε-F1, ruled). Magic RSBK, a fixed little-endian header carrying the two version fields, a length-prefixed JSON manifest, then each entry's payload concatenated in manifest order. Framing is built on core/wire/bytes.h (putLE / ByteReader) and the manifest on core/json — both already owned and tested here. No ZIP, no compressor, no new third-party source in the build; a link edge to vendor/WDL/WDL/zlib/ means the ruling was misread.
  • Two version integers, not one. formatVersion = what this writer emitted; minReaderVersion = the oldest reader that can read it safely. The reader's whole rule is minReaderVersion <= kPackageFormatVersion. An additive change (a new optional manifest key, a new Sample field with a defined absent-value) bumps formatVersion only; a structural change bumps both. Growing a persisted enum's vocabulary is structural here, not additiveBankModel::deserialize rejects an out-of-range SourceMode or Tier rather than degrading it (bank_model.cpp:232-239, :339-346), and every enum a package carries rides inside the nested BankModel blob, so a new SourceMode or Tier value bumps both integers. The header carries the writer's semver (version::stampVersion()) alongside them, informational, so a refusal message can name what to install.
  • The ladder is documented the way origin_ledger.cpp:8-21 documents its own — a header comment listing every shipped version and what changed, with the read-and-validate rule stated, not implied.
  • Unknown manifest keys are skipped (the bank_book_json.cpp:182 behaviour), and unknown persisted enum integers — the manifest's own, not BankModel's nested ones, which reject per the bullet above — degrade to their defined Unknown equivalent, never to the numeric default and never to a parse failure (core/wire/CLAUDE.md's BakeStatus rule, verbatim). Both are pinned by tests, not left to inheritance.
  • The manifest nests BankModel's own serialization verbatim, exactly as bank_book_json.cpp:15-20 nests it, so per-sample shape has one owner and a future Sample field reaches packages for free. Per entry the manifest adds only: the bare file name, the byte length, and a hashBytes digest (core/capture/wav_codec.h:143) — hashBytes, not hashWavContent, because the latter deliberately skips chunks (wav_codec.h:145-151) and so cannot answer "did these bytes survive."
  • The bank's slot_map rides along — display positions are part of what the user built.
  • Framing only, never a payload. bank_package produces the header bytes and an ordered [{ name, offset, length }] layout; it never holds, copies, or hashes an entry's audio. Decode is symmetric: prefix in, manifest + layout out.
  • Path expression is structurally impossible. Entry names are validated to contain no /, \, :, no leading separator, and no .. component, on both encode and decode.

Acceptance criteria.

  • decodePackage(encodePackage(x)) == x over a manifest fixture exercising every field, including every Sample optional in both present and absent states.
  • A synthetic header with minReaderVersion above this build classifies TooNew and no manifest is produced — the decode does not half-succeed.
  • A synthetic header with formatVersion above this build but minReaderVersion at or below it classifies Readable, and its unknown manifest keys are skipped without error. This is the additive-forward-compatibility claim, and it is the reason the two-integer design exists; a test that does not exercise it leaves the design unproven.
  • Truncated input at every byte offset in a valid package returns Malformed — never UB, never a partial manifest, never a read past the buffer. Hostile-input hardening at the bank_model.h:204-206 standard.
  • Entry names containing .., a separator, or an absolute prefix are rejected on encode and rejected on decode. Both directions, because a package can arrive from anywhere.
  • No file in the new directory exceeds ~600 lines; the three-module split above is the responsibility seam, and a fourth module is preferred over a bisection if one is needed.
  • package_format_tests, package_manifest_tests, bank_package_tests all run without REAPER or a DAW.

Open questions. No [Daniel] questions — Ε-F1 is RULED (proprietary RSBK), so this track is dispatchable as written. [propose at review] whether package_format and bank_package are genuinely two modules or one — the split is proposed on responsibility grounds (constants and classification vs. offset arithmetic) and may collapse if the arithmetic turns out to be twenty lines.

Ε-W1-T2 — package-fs-shell

Goal. Every filesystem and dialog act the two verbs need, landed behind an API that knows nothing about what a package contains — so it can be authored, reviewed, and tested in parallel with the format it will carry.

Spec: docs/product/bank-package.md §"Where it lives", §"Failure modes", §"Memory".

Surface boundary — owns: new src/shell/package/package_io (read a file's bytes, write bytes through temp + atomic rename, read one bank file, write one landed file, enumerate the bank folder's existing names, and the rollback delete), the file-picker seam for both verbs — one picker, REAPER's own, on every platform, so this track carries no #ifdef _WIN32 / #else swell/swell.h split. That split is a real pattern in this codebase (src/shell/panel/draw_kit.cpp:11-15, src/shell/persist/prune_fs.cpp:35-38); it is simply not this track's shape, because REAPER owns the dialog. Also owns the directory's CMakeLists.txt and CLAUDE.md, and one appended add_subdirectory line in the root CMakeLists.txt. Does not own: export_bank / import_bank (Ε-W2), anything under core/, and — emphatically — prune_fs, which stays the deletion authority.

Behavior.

  • Atomic write. A package is written to a temp path in the destination directory and renamed on complete success. A failed or interrupted write leaves no .rsbank behind. This is the mono-collapse precedent (Ψ-W2-T2, temp file + atomic rename) applied to a much larger file.
  • Streaming, both ways. Append one payload at a time on write; seek and read one payload at a time on read. The API must make holding the whole package awkward, not merely discouraged.
  • The rollback delete is the carve-out, cited. Its TU header cites src/shell/persist/prune_fs.cpp:5-11 and states the discriminator it satisfies — this call created the file, and no index ever referenced it — rather than restating the carve-out's text. Anything that does not satisfy that discriminator is not this function's business.
  • Both pickers are REAPER's own, and they are symmetric. GetUserFileName(int mode, const char* caption, const char* initial_file_or_path, const char* extension_list, char* fnOutNeedBig, int fnOutNeedBig_sz)verified, vendor/reaper-sdk/sdk/reaper_plugin_functions.h:3790, documented at :3788 — serves both directions: mode=0 ("choose a new file") is export's destination picker, mode=1 ("existing file") is import's source picker. extension_list takes the 'ReaSampler banks|*.rsbank|All files|*.*' form, and initial_file_or_path may be a bare '.rsbank' to set the default extension. There is no #ifdef _WIN32 / SWELL split and no wide-char round trip here — REAPER owns the dialog on every platform, so no GetSaveFileNameW and no BrowseForSaveFile. GetUserFileNameForRead is explicitly "Superseded, see GetUserFileName" (:3796) and is not used.
  • No fallback path. src/app/main.cpp:15 defines REAPERAPI_IMPLEMENT without REAPERAPI_MINIMAL, so the resolver walks the full table — GetUserFileName included (reaper_plugin_functions.h:9084) — and main.cpp:292-293 aborts the extension load if any single function fails to resolve. No REAPER build that loads this extension can lack GetUserFileName, which makes a fallback unreachable code.
  • No REAPER project state is touched here. No ext-state read or write, no undo block, no generation bump; those belong to the verbs in Ε-W2.

Acceptance criteria.

  • A write interrupted before completion leaves the destination path absent or holding its prior contents — never a partial new file. Tested by injecting a failure at the writer seam.
  • Reading and writing a multi-entry package never holds more than one entry's payload; the test asserts against a seam counter, not against a memory measurement.
  • The rollback deletes exactly the files it was given and nothing else, and is a no-op on a path it did not write.
  • There is exactly one picker call site and it is REAPER's. Both verbs reach GetUserFileName — export with mode=0, import with mode=1 — and no symbol named GetSaveFileNameW, BrowseForSaveFile, or GetUserFileNameForRead appears anywhere in src/shell/package/, nor any platform #ifdef in the picker's TU. Greppable, so it stays true. [verify — DAW] — the picker is not exercised in a live REAPER session by this track.
  • No file exceeds ~600 lines; the picker lives in its own TU with its own header. Not for the drag_out / drag_out_win reason — that precedent isolates a Win32-only TU, and there is no platform split here — but because the picker is the only REAPER-facing part of an otherwise REAPER-free, unit-tested module: folding it into a shared header would drag reaper_plugin_functions.h into the testable seam's include graph.

Open questions. [propose at review] where the extension_list and default-extension strings live — this track's picker TU, or the Ε-W2 verbs that call it. They are user-facing text, and the verbs own the rest of the user-facing text; the counter-argument is that they are picker plumbing and only one picker exists. [verify — DAW] two things the header does not answer: (1) whether the mode=0 picker appends an extension when the user types a bare name — :3788 documents that initial_file_or_path may be '.rsbank' "to set the default extension," but not that the dialog enforces it on return, so the verb may still have to append .rsbank itself; (2) dialog parentingGetUserFileName's signature (:3790) takes no owner window, where the abandoned Win32 OPENFILENAME path would have passed GetMainHwnd(), so modality against the REAPER main window is unobserved.

Ε-W1-T3 — import-origin-kind

Goal. Give the ledger a birth-record kind for a package import, so an imported file is tracked from the moment it lands rather than becoming a permanently unreclaimable foreign file — landed as its own track, with its own review, because it edits safety-critical territory that nothing else in this phase touches.

Spec: docs/product/bank-package.md §"What a package deliberately does NOT carry" (the origin-ledger bullet); src/core/tracking/CLAUDE.md for the invariants it must not weaken.

Surface boundary — owns: src/core/tracking/origin_ledger.{h,cpp} and its tests, exclusively. Does not own: tracking_authority (no decision changes), shell/persist, or any consumer.

Behavior.

  • Append OriginKind::PackageImport as value 5. Append only — Capture=1, Ingest=2, Recapture=3, Resample=4 keep their integers, per core/tracking/CLAUDE.md's "PERSISTED INTEGERS — never renumber, only append".
  • A build that does not know value 5 degrades it to Unknown, which is the existing kindFromInt behaviour and is the safe direction: the path is still owned, so still protected; only the kind detail is lost. This is the field-vocabulary rule, and it must stay distinct from the document-version rule right beside it, which blocks (origin_ledger.cpp:18-21).
  • Nothing else changes. No new field, no version bump, no lineage semantics. A new enum value in an append-only vocabulary is precisely the change that does not need "v" to move, and demonstrating that is part of the point.
  • recordCreated needs no signature change — it already takes an OriginKind (src/shell/persist/session.h:95). Confirm that in the same pass; if it turns out otherwise, that discovery is this track's, not Ε-W2's.

Acceptance criteria.

  • A ledger containing a kind-5 record round-trips through serialize/deserialize unchanged.
  • A record carrying an unrecognized kind integer (6, 99, negative) loads as Unknown and the ledger loads Loaded, not Unreadable — the vocabulary gap does not halt prune.
  • kLedgerVersion is unchanged at 2, and a test asserts it, so the append-vs-bump distinction is pinned rather than assumed.
  • pruneProtection's output is unchanged for every existing kind — this track alters no decision.

Open questions. [propose at review] whether the kind is named PackageImport or folded onto the existing Ingest. The plan's recommendation is a distinct value: Ingest means "the user brought in a file," which is close, but losing the distinction makes a future "where did this bank come from" question unanswerable, and an appended integer costs nothing.


Ε-W2 — The two verbs

Depends on Ε-W1 — all three tracks. T1 for the format the verbs speak, T2 for every filesystem act they perform, T3 for the kind their birth records carry. No part of either verb is authorable against a format that has not settled.

Two tracks, disjoint by direction. They share only the manifest type. The split is real enough that the plan pre-split the pure planner into two TUs (export_plan / import_plan) rather than one package_plan — that separation exists specifically so these two tracks do not fight over a file.

Track Owns
T1 bank-export core/package/export_plan, shell/package/export_bank, shell/actions/package_export_action
T2 bank-import core/package/import_plan, shell/package/import_bank, shell/actions/package_import_action, the panel's .rsbank drop route

Two shared files, named — and the disjointness here is CONDITIONAL, unlike W1's. src/app/main.cpp (one action-family registration line each) and the panel's bank menu (one row each). Both are textual adjacency by construction, but Phase Ψ set the precedent of granting main.cpp to a single track rather than sharing it (Ψ-W1-T3). If the dispatcher wants zero contention, serialize T2 behind T1 — T2 is the larger track and loses nothing by starting second. The plan's default is to run them in parallel and rebase whichever lands second.

Ε-W2-T1 — bank-export

Goal. One bank leaves the project as one file, or the export refuses and says why.

Spec: docs/product/bank-package.md §"Failure modes" (export rows), §"What a package carries".

Surface boundary — owns: new core/package/export_plan (pure: which entries, what names, what is missing, and therefore whether the export may proceed), new shell/package/export_bank (the promptless verb — takes a ReaSamplerSession&, returns an outcome, no prompts and no message boxes, mirroring src/shell/bank_ops/), new shell/actions/package_export_action (the bindable-action skin, mirroring prune_action), one registration line in src/app/main.cpp, one panel menu row. Does not own: anything on the import side, bank_ops, or persist.

Behavior.

  • The exported unit is one bank — the pool included, since the pool is structurally a bank (core/model/CLAUDE.md's pool-privileges section). Whole-book export is an explicit non-goal of this phase and is preserved as an additive future by the manifest's shape, not by a promise.
  • Refuse-if-incomplete, report-before-acting. An index entry whose file is missing or unreadable stops the export by default; the "export the N present entries" path exists only behind an explicit confirm that lists what is absent, distinguishing missing from unreadable. This is prune's dry-run-then-confirm discipline applied to a non-destructive act, and it is deliberate: a silently-incomplete package is discovered on the far side, in another project, weeks later.
  • The project is not touched. No ext-state write, no generation bump, no undo point. An export that mutates project state is a defect, and the acceptance criteria name it as one.
  • Nothing is re-encoded. Payload bytes are copied and hashed. wav_codec is not asked to rebuild anything.
  • A new FOREVER-STABLE command id is minted through version::channelCommandId(suffix) per the root CLAUDE.md action contract, with its per-channel display name through channelActionName.

Acceptance criteria.

  • planExport is pure and total over its inputs: a bank with a missing file, an unreadable file, zero samples, and one sample all classify without touching a filesystem.
  • Exporting a bank and re-reading the package yields, for every entry, a hashBytes digest equal to the source file's — asserted per entry, not in aggregate.
  • The exported manifest contains no absolute path and no path separator, asserted by a test that scans the emitted bytes rather than by inspecting the model.
  • A failure injected mid-write leaves no .rsbank at the destination and the prior file, if any, intact.
  • Project ext state is byte-identical before and after an export, and bankGeneration() is unchanged — a direct assertion, because "we did not mean to write anything" is not a property that survives without one.
  • Exporting an empty bank produces a valid, importable package with zero entries rather than refusing. An empty bank is a legitimate thing to carry.

Open questions. [propose at review] whether the export affordance is action-only, panel-only, or both at ship. [propose at review] whether the default file name is derived from the bank's display name (recommended, sanitized through capture_paths::sanitizeStem) or from the project name.

Ε-W2-T2 — bank-import

Goal. A package becomes a new bank in this project — completely, or not at all — with every one of the four collision classes answered explicitly rather than by whatever the model happens to do.

Spec: docs/product/bank-package.md §"Identity and collision on import" (including the auto-suffix rule), §"Failure modes" (import rows), §"Import under a degraded tracking ledger", §"Version tagging: both directions".

Surface boundary — owns: new core/package/import_plan (pure: the id remap table, the parent remap, the per-entry write / skip-already-present / rename disposition, the destination bank name after uniqueness folding), new shell/package/import_bank (the promptless verb), new shell/actions/package_import_action, the panel's WM_DROPFILES route for a .rsbank (routing only — the existing ingest route for audio files is untouched), one registration line in src/app/main.cpp, one panel menu row, and — the only core/model/ edit in the phase — one additive public const member on BankBook (recommended std::string uniqueDisplayName(const std::string& seed) const), so the auto-suffix probe runs behind the model's own name fold. Does not own: anything on the export side, bank_book's rules (consumed, never re-implemented — the new member exposes the existing fold, it does not add a second one), origin_ledger (Ε-W1-T3's).

Behavior.

  • The ledger guard runs FIRST — before the file picker opens (Ε-F3, ruled: refuse). If tracking::ledgerDegraded(status) holds for the project's loaded ledger status (Unreadable or FutureVersion; core/tracking/origin_ledger.h:94, :100-101), the import refuses outright — no picker, no bytes read, no confirm-and-proceed path, no opt-out. Fresh and Loaded both proceed. Do not key this on PruneReport::blockedByTracking: that flag also fires on undecodable rsusage_* keys, which govern deletion-time protection and have nothing to do with writing birth records. The refusal is a ShowConsoleMsg block mirroring prune_action.cpp:30-69 in structure and tone, with two variants (malformed / newer-build) and every recovery line naming this build's namespace through version::extStateNamespace(). Exact wording in the spec doc. Export is deliberately not gated this way — that is Ε-W2-T1's, and it stays ungated.
  • Version gate second, before any byte is written. minReaderVersion above this build refuses the whole package and reports through ShowMessageBox (verified, reaper_plugin_functions.h:6546) naming three things: the package's requirement, this build's ceiling, and the writer's semver. Two of the three is not enough to act on. A malformed or truncated package reports distinctly — the two failures have opposite recoveries, which is exactly why origin_ledger.cpp:178-185 separates them.
  • Four collisions, four answers. (1) Sample id — remint every id and remap Provenance::parentSampleId (bank_model.h:45-50) through the same map, to the reminted parent when it came in the same package and cleared otherwise; a foreign id never enters the index. (2) File name — never overwrite; mint a fresh unique name through capture_paths::deriveBankPaths, silently, counted in the summary. (3) Content hash — consult BankModel::findByHash before writing the payload; on a hit, skip the write entirely and let the entry collapse, so a dedup never manufactures an orphan. (4) Bank display nameauto-suffix, no prompt (Ε-F2, ruled). Seed = the package's recorded source bank name verbatim (or the literal Imported bank if absent/blank); take the first of seed, seed + " 2", seed + " 3", … whose fold is free in the destination book, ascending from 2. Four points that decide the behaviour and must not be re-invented: the seed is never re-parsed ("Drums 2" colliding lands as "Drums 2 2", not "Drums 3" — a bare trailing integer is indistinguishable from "Kit 808"); the probe fills gaps (first-free, not highest-plus-one, so it is a pure function of the current name set); the probe terminates by pigeonhole within B + 1 candidates for B banks, so no arbitrary cap; and the fold is BankBook's own (bank_book.h:252-258), reached through the new public member, never re-implemented in import_plan. Sample display names are not suffixed, and slot_map positions are untouched.
  • Always a new bank; never a merge (Ε-F2, ruled). The import creates a bank — it never merges into an existing one, never lands into the pool, and offers no target picker. A pool export therefore lands as a named bank "Pool 2", which is correct, not a glitch. This track ships one action, not two; merge-into-existing is out of scope for the phase, and move/copy already cover the after-the-fact case.
  • Birth records at landing. Every landed file goes through ReaSamplerSession::recordCreated(sample, OriginKind::PackageImport) at the same point the Sample is added, in the same straight-line block, per core/tracking/CLAUDE.md's no-silent-gaps invariant. An import that lands a file without a record is the exact failure that section exists to prevent.
  • All-or-nothing, with rollback. Any failure after the first write deletes the files this call wrote and abandons the index mutation. The rollback cites the prune_fs.cpp:5-11 carve-out; it does not restate it, and it does not reach outside the set it wrote.
  • One Ctrl-Z for the index, and the file residue is stated, not implied. The index mutation batches through persistBankOp (Undo_BeginBlock2 / Undo_EndBlock2, verified at reaper_plugin_functions.h:7758 / :7806). Undo does not un-write the files; they remain as orphans until a prune reclaims them — the same designed window a non-empty bank delete already produces (core/model/CLAUDE.md's sample-removal section). The user-facing summary says so.
  • bumpBankGeneration() on success (session.h:108), so live instances reload.
  • No timeline item is placed. Ever.
  • A new FOREVER-STABLE command id, minted the same way T1's is.

Acceptance criteria.

  • planImport is pure and total, and every one of the four collision classes has a test that exercises it without a filesystem: colliding ids, colliding file names, a hash already present, and a colliding bank name.
  • Importing a package built from bank B back into the project that already contains B lands a new bank named "B 2", with every id reminted, no entry lost, and B itself unmutated. Importing it a third time lands "B 3".
  • The suffix probe is pinned by pure tests over a name set, covering at minimum: a free seed (no suffix applied), a case/whitespace-folded collision ("drums" blocks "Drums"), a gap ("Drums" + "Drums 3" present ⇒ "Drums 2"), a seed that already ends in a number ("Drums 2" colliding ⇒ "Drums 2 2"), an absent/blank recorded name (⇒ Imported bank), and a package whose source bank was the pool (⇒ "Pool 2", a named bank).
  • A degraded ledger (Unreadable and FutureVersion, both asserted) refuses the import with no picker shown, zero files written, zero index mutation, and the message names the channel-correct ext-state namespace. An undecodable rsusage_* key with an otherwise Loaded ledger does not block — asserted, because the tempting reuse of blockedByTracking would silently make it.
  • An entry whose payload fails its hashBytes check aborts the import with zero files landed and zero index mutation — asserted on both, since either alone would pass a weaker test.
  • A write failure injected at entry k of n leaves exactly zero files from this import on disk and the index unchanged.
  • Every landed file has a ledger birth record with kind PackageImport, asserted by reading the ledger after the import, not by counting calls.
  • minReaderVersion above the build: nothing written, message names all three facts. formatVersion above the build with minReaderVersion at or below it: imports cleanly, unknown keys skipped. Both directions asserted, in this track, against real package bytes.
  • No timeline item exists after an import; the arrange is byte-identical.
  • DAW-verification obligation (to be discharged by Daniel, not by this track): import a package produced on another machine, confirm the panel shows every sample with its metadata, confirm a live ReaSampler 9000 instance picks up the new bank content on the generation bump, and confirm one Ctrl-Z removes the index entries.

Open questions. No [Daniel] questions — Ε-F2 and Ε-F3 are both RULED (new bank always with an auto suffix; refuse on a degraded ledger), so this track is dispatchable as written and ships one action. [propose at review] whether the import summary is a console block, a message box, or both; the recommendation is a console block plus a one-line message box, so the detail is copyable and the outcome is unmissable. Note the refusal path is already fixed at a console block by the Ε-F3 spec, so this call is about the success summary only.


Ε-W3 — The compatibility fixtures

Depends on Ε-W2 for: both verbs existing. A round-trip claim cannot be tested against one half of a round trip, and a "this build refuses a future package" claim cannot be tested against a package this build is incapable of writing.

One track. The whole deliverable is one corpus and the harness over it; splitting it would mean two tracks writing two halves of one fixture set.

Ε-W3-T1 — package-compat-fixtures

Goal. Turn the version-compatibility policy from an assertion in a doc into a property proven against frozen bytes, so a later format change cannot silently break either direction.

Spec: docs/product/bank-package.md §"Version tagging: both directions".

Surface boundary — owns: a new checked-in fixture corpus under the package modules' test tree, the harness that decodes it, and one docs/ verification script for the DAW half. Does not own: any production module — if a fixture reveals a defect, the fix is filed against the owning track's module and this track carries the failing test, not the patch.

Behavior.

  • Frozen bytes, not regenerated ones. The corpus holds real .rsbank bytes committed to the repo: a v1 package written by the shipping build, a synthetic formatVersion = N+1 / minReaderVersion = current package (the additive-forward case), and a synthetic formatVersion = N+1 / minReaderVersion = N+1 package (the refuse case). A test that regenerates its own fixture proves only that the code agrees with itself — which is precisely the failure mode a format ladder exists to catch.
  • A truncation corpus. The valid package truncated at a spread of offsets, each asserted Malformed rather than TooNew, so the two recoveries never get crossed.
  • A hostile-name corpus. Packages whose entry names carry .., separators, and absolute prefixes, each refused.
  • The round-trip anchor. Export → import → export over the v1 fixture yields byte-identical payloads.
  • A standalone DAW verification script in docs/, following the docs/verify-track-scope-multitrack.md precedent — the cross-machine transfer is the one claim no unit test can make.
  • The corpus is append-only. When a future format version ships, its fixture is added; no existing fixture is ever regenerated or edited. Stated in the corpus's own README so the rule survives the person who wrote it.

Acceptance criteria.

  • All three version fixtures classify as specified, and the additive-forward one imports with every known field intact and every unknown key skipped.
  • Every truncation offset classifies Malformed; none classifies TooNew, Readable, or crashes.
  • Every hostile-name fixture is refused at decode, before any planner runs.
  • The round-trip fixture's payloads are byte-identical after export → import → export.
  • The DAW script exists and names its steps concretely enough to run without reading this plan.

Open questions. [propose at review] how large the committed corpus is allowed to be — the recommendation is one-sample packages with a few hundred bytes of payload each, since the properties under test are structural and a large payload proves nothing extra.


Phase Ρ — Render in place: a track's output to a new sibling, source to the bench

Ships: one bindable action that renders the selected track's output over the current range to a file outside the bank, drops that file as an item on a brand-new sibling track at the exact position it was rendered from, clones the source's colour and its name with an idempotent Capture prefix, moves the source track into Design mode — where Design View's existing park hides it, takes it out of the mix, and puts its FX offline — and puts the result track into Arrange, unconditionally. The bank is never read, never written, and never notified.

Consolidates: none of the seventeen. Phase Ρ came from a direct request (Daniel, 2026-08-02) and is scoped in docs/product/render-in-place.md. Daniel's framing, verbatim in substance: similar to REAPER's "Render selected track time selection to new track (stereo) and mute original", except instead of muting the original, the source track stays/goes to design mode, and the resulting new sibling track — which gets the rendered audio item placed correctly in the timeline — stays in whatever mode was active when the action was run; the new track clones the source track colour and name with a Capture prefix; this must NOT put the rendered audio into the ReaSampler banks/pool. The mode-following clause in that framing was superseded by Daniel's own Ρ-F2 ruling the same day"for this action which is not a capture, the result track should always go to arrange" — and the quote is kept verbatim only as the record of the request. It supersedes nothing — a sweep of docs/TODO.md and docs/TODO-1.0.md for render.in.place|render to new track|preserve.source returns nothing.

The framing answer — why this does not breach the load-bearing principle

Root CLAUDE.md's rule — capture and placement are separate acts; any code path that auto-inserts a capture into the timeline must be rejected in review — is a rule about the bank, and Phase Ρ does not put the bank on either side of its verb. The full argument is docs/product/render-in-place.md §"The third verb"; the operative summary, which every review of this phase must apply:

Verb Source Sink Touches the bank
Capture (RunCapture, batch, realtime, bake, ingest) arrange / instrument bank writes it
Placement (RunInsertSelected, performArrangeDrop) bank arrange reads it
Render in place (this phase) arrange arrange never

What Ρ shares with capture is the renderrenderOffline, FxBypassGuard, exact custom time bounds, RENDER_ADDTOPROJ = 0, the multi-track refusal — not the capture. A capture is a render plus a bank landing; Ρ takes the mechanism and declines the landing.

Four boundary conditions, three of them structural, all review-rejectable:

  1. Ρ's shell never calls session.bank(), session.book(), session.recordCreated(), or session.bumpBankGeneration(). The Sample the backend returns is discarded, and on the ProjectMedia destination its relativePath is left empty — a Ρ Sample is inert by construction.
  2. Ρ cannot express "write into the bank folder." The destination reaches the backend as a two-valued enum, never a caller-supplied path string. A renderDir string on CaptureRequest instead of the enum is the drift, and is rejected on sight.
  3. Ρ's file is never recorded as owned, so prune ((owned ∩ present) referenced) cannot reach it — and it lives outside the bank folder, so prune's enumeration never sees it either. Two independent layers. The tool deletes only what it owns; a render-in-place file belongs to the project.
  4. Traffic is one-way. Ρ may borrow capture's render; capture may never borrow Ρ's placement. No place flag on CaptureActionDef, no "capture and also place" action, ever.

Phase-Ρ acceptance criteria

These bind the track in this phase, in addition to the plan-wide set above.

  • Placement is sample-exact and unsnapped. The item lands at the render window's startSeconds, unrounded, with SnapToGrid deliberately not applied (unlike performArrangeDrop). Ρ's placement is the null test performed automatically — a render of a range re-inserted at its source position nulls to silence against the source — so a snapped or rounded placement is a phase failure, not a rough edge.
  • No tempo conform, ever. computeInsertMode(InsertOptions{}) only; insert_plan already guarantees the &4 stretch-to-time-selection bit is never set. No conform variant of this action is offered.
  • The bank path is byte-identical to today. The CaptureDestination enum defaults to Bank; every existing capture entry point must produce exactly the file, path, hash, and index entry it produces now. If any capture test changes expectation, the seam is wrong.
  • The new track is bare. InsertTrackInProject(proj, p, /*flags=*/0) — flags&1 adds default envelopes/FX (SDK header 3954) and a default chain would process the render a second time.
  • Both I_FOLDERDEPTH writes, or none. The sibling-placement arithmetic is pure and unit-tested before any DAW work; a render that lands the new track at the wrong nesting level is audibly wrong in both directions (double-processed through a folder it re-enters, or bypassing the folder bus entirely).
  • The Ρ-F2 ruling survives the auto-tag detector. The result track and its placed item are tagged kArrangeModeId explicitly (a membership record, not an untag() to the Arrange default), and panel_input::detectNewContent drops added GUIDs that already carry a record. Without that filter the detector tags every new track to the active mode on its next tick and a Design-fired render silently becomes a Design member — the ruling reversed inside a second. This criterion replaces the tag-before-reapply ordering criterion the phase carried under mode-following: with the result track an Arrange member unconditionally, a Design reapply parking it is the correct outcome and the ordering is state hygiene, not behaviour.
  • One undo block, UNDO_STATE_ALL, opened before the track is created and closed after the mode reapply; the render sits outside it. persistViewState runs after the block closes — it may raise a Save-As dialog, which must not sit inside an open undo block (design_view_actions::doMoveItems' documented ordering).
  • Every pure module gets a <module>_tests target. All three pure additions land in existing core/capture modules that already have one.

Performance posture. Every surface is cold — one gesture, once. None of the named hot paths (peaks envelope compute, audition, the realtime-capture tick's single-pointer-test idle fast path, the instrument's process()) is touched.

Concurrency. Phase Ρ is extension-side. Γ lives in core/instrument/ + shell/instrument/; Ε lands in the new core/package/ + shell/package/. The pre-existing files Ρ edits are named in its track's surface boundary and intersect neither — including shell/panel/panel_input.cpp, which the Ρ-F2 ruling adds: no in-flight track in this plan touches that file (Phase Ψ's two named regions in it, the footer block and the drag-arm block, are both landed, and both are functions other than detectNewContent).

Rulings — Daniel's, 2026-08-02. Nothing open.

All three forks this phase opened were ruled the day it was framed. Full statements, the counter-arguments that made each a fork, and the one unexercised alternative: docs/product/render-in-place.md §"Rulings".

  • Ρ-F1 — multi-track. RULED: refuse. One selected track per fire, inherited from isMultiTrackStemRender. A settled non-goal, in the same register as the other entries under §"What Phase Ρ explicitly is NOT" — not deferred-with-a-plan. There is no per-track loop planned, no second wave holding one, and no seam to leave half-open for it. Matches the framing recommendation; nothing in the track changed.
  • Ρ-F2 — the result track's mode. RULED: always Arrange. "For this action which is not a capture, the result track should always go to arrange." This overrode the framing and the request's own original wording — the result track no longer follows the active mode. The source still goes to Design. Three consequences, all specced below: the result track and its item are tagged kArrangeModeId explicitly; firing from Design produces no visible change (the A/B-on-the-bench behaviour mode-following would have enabled does not exist and must not be cited as a benefit anywhere); and the panel's auto-tag detector needs a two-line explicit-tag-wins filter, or it re-tags the result track to Design on its next timer tick and silently reverses the ruling.
  • Ρ-F3 — tail. RULED: follow the panel tail settings. Matches the framing recommendation; nothing in the track changed. Two consequences, accepted rather than caveated: under Auto/Manual the placed item is longer than the window it replaces (correct for a decaying chain, wrong for a butt-joined section — the user's lever is the panel's own tail setting), and the exact-bounds gate is inactive in those two modes because it runs only under TailMode::None. Both are inherited from every other capture path, not introduced here. The item's start is exact in all three modes, so the null test holds in all three. The third option floated at framing (run the gate's start-alignment check regardless of tail mode) is not ruled in and is recorded in the product doc as an unexercised alternative.

Ρ-W1 — The verb

Depends on: nothing.

One track, deliberately. The whole phase is roughly 350 lines: three small pure additions to existing core/capture modules, one bounded edit to the offline backend, one new shell TU, a two-line filter in panel_input.cpp, one ActionTableRow, four invariant amendments. Splitting it would create a merge dance across core/capture for no gain, and the pure half cannot be reviewed meaningfully apart from the caller that gives it meaning. Named contingency: if the sibling-placement arithmetic balloons in implementation, that function is the natural split point — it is the only piece with zero dependency on anything else in the track.

Ρ-W1-T1 — render-in-place

Goal. One bindable action: render the selected track's output over the current range to the project's recording path, place it on a new sibling track at the exact render position, clone colour and name, move the source to Design and the result track to Arrange — always Arrange, whatever mode was active (Ρ-F2).

Spec: docs/product/render-in-place.md — §"The render", §"Where the file goes", §"Placement", §"The new track", §"Mode transitions", §"Undo", §"The action".

Surface boundary — owns:

  • New: src/shell/capture/render_in_place.{h,cpp} (the action body) and its CMakeLists.txt entry. It lives in shell/capture/ rather than shell/actions/ because it composes renderOffline and ResolveScopeSource — that directory's CLAUDE.md places action bodies here and reserves shell/actions for skins over mutation logic owned elsewhere.
  • Extends (existing modules, existing test targets): core/capture/track_topology (siblingPlacement), core/capture/capture_name (captureTrackName), core/capture/capture_paths (RenderPaths + deriveRenderPaths, with deriveBankPaths re-expressed over it so the file-stem spelling keeps one owner — bankRelativeForName already depends on that).
  • Edits, bounded: src/shell/capture/capture.{h,cpp} — the CaptureDestination enum on CaptureRequest, the ~6-line destination branch at path derivation, and CaptureResult::absolutePath. src/shell/panel/panel_input.cpp — two lines inside detectNewContent only, dropping added GUIDs that already carry a membership record (MembershipIndex::query(guid) != nullptr) before the autoTagNewContent call. That edit exists only because of the Ρ-F2 ruling. src/app/main.cpp — one ActionTableRow.
  • Does not own: anything under core/instrument/, shell/instrument/, core/package/, shell/package/, core/model/, core/tracking/, core/reclaim/, or shell/persist/. No new directory. No new persisted state, no new ext-state key, no new wire version rung.

Behavior.

  • Resolve via ResolveScopeSource(CaptureScope::Track, …) — razor-else-time range, selected tracks, canonical GUIDs, source track name. No range → refuse with the reason resolveRange produced. Item extent is not a fallback and must not become one.
  • Refuse multi-track by inheritance. renderOffline fires isMultiTrackStemRender before touching anything, keyed on the render source (SelectedTracks), so Ρ adds no check of its own and gets multiTrackRefusalMessage(CaptureScope::Track) for free.
  • Render through renderOffline(CaptureScope::Track, src.sourceTracks, req) with req.destination = ProjectMedia, tail from bankPanelTailSetting(), channelCount = 2, Float32, sampleRate = 0, and the name from captureNameFor(src.trackNames, 0, "capture"). Inherits the FX-bypass guard, the render-selection guard, the RENDER_* snapshot/restore, RENDER_ADDTOPROJ = 0, the exact-bounds gate, the unsaved-project Save-As gate, and the lossless mono collapse — all unchanged.
  • Destination resolves in the backend, after its own save gate, so an unsaved project is still prompted before any path arithmetic runs. ProjectMediaGetProjectPathEx(proj, …) (SDK header 2550; header 3102 names it as the way to get the effective recording path when RECORD_PATH is blank or relative). The relative-paths-only invariant is untouched — it binds the BankIndex, and Ρ writes to no index.
  • Sibling placement (pure, in track_topology): prefix-sum I_FOLDERDEPTH to absolute levels; L = level[srcIdx]; if depth[srcIdx] >= 1 the insert position p is the first j > srcIdx with level[j] == L (else count), otherwise p = srcIdx + 1; then exactly two writes — depth[p-1] = L - level[p-1] and the new track's depth = level[p] - L (with level[count] = 0). Total delta sum preserved, so nothing downstream shifts. The five cases and their expected outcomes are tabulated in the product doc; a malformed project whose deltas do not sum to zero clamps rather than asserts.
  • Create + dress: InsertTrackInProject(proj, p, 0), GetTrack(proj, p); SetMediaTrackInfo_Value(new, "I_CUSTOMCOLOR", (double)GetTrackColor(source)) — one line clones a colour and the absence of one, since GetTrackColor returns the value already OR'd with 0x1000000 and 0 means unset; GetSetMediaTrackInfo_String(new, "P_NAME", buf, true) with captureTrackName(trackName(source)).
  • Name rule (pure, tested): "Capture " + sourceName, idempotent — if the source name already begins with the prefix, the new name is the source name verbatim, so a second run yields Capture MONEY, never Capture Capture MONEY. An unnamed source yields Capture Track N (trackName uses GetTrackName, which already answers REAPER's own convention — the Ψ-W2-T1 precedent). A counter suffix is rejected: REAPER does not uniquify track names either, and it is a treadmill.
  • Place: snapshot the edit cursor → SetOnlyTrackSelected(new)SetEditCurPos(src.startSeconds, false, false)InsertMedia(absolutePath, computeInsertMode(InsertOptions{})) → restore the cursor. Leave the new track selected, alone — a deliberate divergence from the restore-the-selection convention every other placing path follows, because in the headline case the source is being parked out of sight in the same gesture and restoring the selection would leave the user selecting an invisible track.
  • Modes (Ρ-F2, RULED — absolute, not mode-following): membership().tag(sourceGuid, kDesignModeId) (covers stays and goes — tag replaces prior single-mode membership); membership().tag(newTrackGuid, kArrangeModeId)kArrangeModeId unconditionally, never view.activeModeId(); membership().tag(…, kArrangeModeId) for each item on the new track (enumerate after InsertMedia; the track is brand new so those are exactly the items just placed — item_read::itemGuid is the existing GUID seam); then mintManagedLanes(view, nullptr); then applyMode(view, view.activeModeId(), nullptr) — a reapply, never a switch, so it is not transport-gated and does not touch solo.
  • Why explicit tags and not untag(): an untagged GUID is an Arrange member by behaviour but carries no membership record, and the record is what the auto-tag detector's new filter keys on. Tag, don't untag. This is the first explicit kArrangeModeId record in the tree — the shipped tag selected tracks → Arrange action dispatches to doUntag(), i.e. Arrange-by-absence. The record is well-formed and behaviourally identical (isMember answers the same for both states, untag() still clears it); it costs one persisted entry. Unit-test the JSON round-trip; [verify — DAW] that such a project shows no view-behaviour difference.
  • Fired from Design, the result track is parked and nothing is visible. That is the ruled behaviour, not a bug: the result track is an Arrange member, so a Design reapply parks it, and it appears in the source's place on the next switch to Arrange. The tag-before-reapply ordering that mode-following made load-bearing is now state hygiene only.
  • Selection stays absolute too. The result track is left selected, alone, in both cases — even fired from Design, where that selects a parked track. Making selection conditional on the active mode would reintroduce exactly the mode-relative behaviour Ρ-F2 removed.
  • Inherited and deliberately not fought: show-both on the source is not cleared (it is the user's pin); a folder-parent source is not hidden by tagging, because parents are derived (core/view/CLAUDE.md) — the existing tag action behaves identically. Do not invent a cascade that tags the children.
  • Feedback: silent on success (the new track is the feedback), ShowConsoleMsg on every refusal — matching RunInsertSelected. [propose at review] whether the Design-fired path breaks that silence with a one-line ShowConsoleMsg naming the track it created: under Ρ-F2 that path produces no visible change, so a silent success is indistinguishable from a no-op. Recommendation: yes, one line and one string.
  • Action: suffix RENDER_TRACK_IN_PLACE — FOREVER-STABLE per channel, minted as a new RENDER_* verb family rather than a CAPTURE_* member, deliberately: the id is permanent and is the most durable statement the codebase makes about which pillar a feature belongs to. Phrase: "render selected track to a new track (source moves to Design)". Main section only; one ActionTableRow; no custom_action/hookcommand2.

Invariant amendments — deliverables of this track, not follow-ups (the Phase Ψ precedent, where three such amendments were acceptance criteria of the tracks that broke them). A track that lands Ρ without these reads as an invariant breach in review.

  1. src/shell/capture/CLAUDE.md §Invariants — "RunInsertSelected is the one deliberate exception to capture-never-places." Amend to state that this directory now hosts two placing paths and give the discriminator: RunInsertSelected places a bank sample; render_in_place places a render that never entered the bank. Neither is a capture placing itself.
  2. src/shell/actions/CLAUDE.md §Invariants — "arrange_drop_win is the only timeline-placing shell in this directory." Scope the sentence explicitly to that directory and cross-reference the third verb.
  3. Root CLAUDE.md §"The load-bearing principle" — one sentence, not a rewrite: a render that never enters the bank and never leaves it is a third verb outside the rule, with the two-way boundary named. The prohibition must not be softened; the exception must be named precisely.
  4. src/core/view/CLAUDE.md §Invariants — "New tracks are tagged to the active mode at creation." Added by the Ρ-F2 ruling. That rule now applies only to a GUID carrying no membership record: an explicit tag wins over the detector. Amend the sentence and state the reason in one clause — the detector classifies content the user made, not content the tool made and already classified. src/shell/panel/CLAUDE.md describes panel_input only as "the new-content auto-tag timer" and does not restate the rule, so it needs no amendment.

Acceptance criteria.

  • siblingPlacement is unit-tested over all five cases in the product doc's table (normal mid-folder, last-in-folder -1, last-in-two-folders -2, folder parent, last track in the project) plus a malformed non-zero-sum delta list, with no DAW.
  • captureTrackName is unit-tested for plain, already-prefixed (idempotence), empty, and Track N sources.
  • deriveRenderPaths round-trips the same stem spelling deriveBankPaths produces for the same inputs, and the existing capture_paths tests pass unchanged.
  • Every existing capture test passes with no expectation change — the proof that the destination seam is inert on the Bank path.
  • render_in_place.cpp contains no reference to session.bank(), session.book(), recordCreated, or bumpBankGeneration — checkable by grep, and the review gate for boundary condition 1.
  • The action registers, dispatches, and mirror-unregisters through the single ActionTableRow — no separate registration mechanism.
  • render_in_place.cpp contains no reference to activeModeId in the tagging path — checkable by grep, and the review gate for the Ρ-F2 ruling. The result track and its item are tagged kArrangeModeId; activeModeId() appears only in the applyMode reapply.
  • detectNewContent no longer auto-tags a GUID that already carries a membership record, and the existing view_mode_model / guid_diff tests pass unchanged (the filter is shell-side; autoTagNewContent's pure contract does not move).
  • A membership index carrying an explicit kArrangeModeId record JSON-round-trips unchanged, and isMember answers identically for that record and for an absent one — one added view_mode_model test, no DAW.
  • All four invariant amendments are in the diff.

DAW-verification obligation (stated up front; nothing past the pure functions is unit-testable):

  • The null test on Ρ's own output — render a track over a range, polarity-invert the source against the new track, confirm silence. The phase's trust anchor.
  • The three folder cases, each confirming the new track's nesting level and that the render feeds (or correctly bypasses) the folder bus. [verify — DAW] whether InsertTrackInProject plus the two I_FOLDERDEPTH writes settle without an intermediate TrackList_AdjustWindows(false) (SDK header 7735; header 2721 notes some attribute writes need a manual panel update, and the isMinor semantics are undocumented).
  • The collapsed-mono placement — render a dead-centre source, confirm a mono item, and confirm it sums at the same level the stereo source did. This is root CLAUDE.md's existing [verify — DAW] on mono-item-on-stereo-track summing, promoted to load-bearing by this phase: Ρ is the first path where a collapsed render is placed into the mix by the tool itself.
  • Both mode transitions under the Ρ-F2 ruling — fired from Arrange (source parks; result track visible and in the mix) and fired from Design (source stays on the bench; result track parked, then present in the source's place after a switch to Arrange). In each case wait out at least one panel timer tick and re-check the membership — that is the auto-tag-detector regression, and it is what catches a missing explicit-tag-wins filter or an untagged item, either of which silently reverses the ruling. The old ordering check (that the new track is never momentarily parked) no longer applies.
  • Undo — one Ctrl-Z removes track and item and reverts the folder-depth write; the file survives (REAPER's undo deletes no files, and prune cannot reach this one); the source stays tagged Design, whose way out is the existing tag selected tracks → Arrange action. All three residuals are inherited from the documented model-vs-undo split (src/shell/view/CLAUDE.md §Gotchas), not introduced here.
  • Name and colour clone, including a second run over an already-prefixed track (must not stack) and an unnamed source (must read Capture Track N).
  • GetProjectPathEx against a project with a non-default recording path, confirming the render lands where the project's media lives.

Open questions. No [Daniel] questions — Ρ-F1, Ρ-F2 and Ρ-F3 are all RULED (2026-08-02), so nothing here is gated. Two [propose at review] items, both recommendation-carrying. First, whether the Design-fired path emits a one-line ShowConsoleMsg (see the Feedback bullet) — recommendation: yes. Second, [propose at review] whether render_in_place should refuse when the source track is the master — ResolveScopeSource collects via CountSelectedTracks/GetSelectedTrack, which skip the master (SDK header), so a master-only selection already resolves to zero tracks and refuses with "nothing selected"; the recommendation is to leave that inherited behaviour alone rather than add a message for a case the existing path already handles correctly.


Phase Λ — ReaSampler on Linux: both artifacts, shipped

Ships: reaper_reasampler.so and reasampler_9000.vst3 built, installed and documented on Linux. The extension compiles under GCC/Clang, builds optimized by default, docks its panel, writes a bank index that survives a comma-decimal locale, and prunes without lying about what it reclaimed. The instrument loads, scans, instantiates and processes audio in any Linux host, and opens its editor under REAPER only — a stated contract, not a shortfall. macOS is out.

Four named exceptions to platform parity, stated up front rather than discovered: prune deletes permanently (no trash); the OS drag-out's copy-only guarantee is conventional rather than structural, because SWELL's file-list drag takes no effect mask; the type faces are DejaVu rather than Segoe UI and Consolas; and the ReaSampler 9000 editor comes up under REAPER only, with every other host getting its generic parameter UI.

Consolidates: none of the seventeen. Phase Λ came from a direct request (Daniel, 2026-08-02) and is scoped in docs/product/linux-readiness.md, which is itself downstream of two landed audits: docs/product/audit-notes/lambda-w1-t1-build-toolchain.md (findings Λ-01…Λ-10, verify items V1…V10, forks D1…D7 — build system, toolchain, vendored deps, resources, test harness, packaging) and docs/product/audit-notes/lambda-w1-t2-source-runtime.md (L2-01…L2-12 — source portability and runtime behaviour). Grep those for a cited finding number; do not read either whole. This phase supersedes nothing, and corrects one standing promise rather than inheriting it: docs/product/versioning-and-release.md already commits in writing to "three platform artifacts per channel per release" (T1 §1e), which Λ-D4 makes two for now, with macOS named as deferred rather than silently dropped.

Nothing in this phase has been verified on a Linux machine. Every [verify — Linux] mark below is load-bearing and none may be laundered into settled voice. The structural consequence is stated once here because it shapes the whole wave order: Λ-W2's edits are authored blind from the audits' citations, and Λ-W3 is the session that discharges Λ-W2's acceptance criteria. Λ-W2 is not "done" in the usual sense until Λ-W3 runs, and a track that reports otherwise has laundered unverified work into landed work — which is exactly what the audits' [verify] discipline exists to prevent. Both audits state their own effort bands as provisional until the sweep runs; this plan does not restate a band the audits did not give.

Rulings — Daniel's, 2026-08-02. Six, settled.

Full statements with the reasoning Daniel gave: docs/product/linux-readiness.md §"Settled decisions (Daniel, 2026-08-02)". Do not re-litigate these.

Ruling What it settled Specified in Bound into
Λ-D1 The VST3 instrument is in scope (audit fork D1 = Fork B). Both artifacts ship, not just the extension. Reverses D5's platform clause in writing — "Windows-only, VST3-only, REAPER-only" becomes "Windows and Linux; the editor is REAPER-hosted"; the VST3-only and REAPER-only clauses survive verbatim §Λ-D1 Λ-W6…Λ-W8; the D5 rewrite is Λ-W6-T1's first act
Λ-D2 SWELL is reached by dlopening REAPER's own libSwell.so (route B3a). Route B3b — building SWELL into the module, and with it GDK/GTK3, FreeType, Fontconfig, OpenGL — is rejected, not deferred §Λ-D2 Λ-W7-T1
Λ-D3 Non-REAPER hosts must be protected"REAPER-only is good for now, but we need to protext other hosts." A first-class acceptance criterion of two tracks, not a footnote on one §"The non-REAPER-host safety criterion" Λ-W6-T2 (contract), Λ-W7-T1 (runtime), Λ-W8-T1 (regression)
Λ-D4 macOS is out (D3 = out) — "I don't have a mac to compile on." Shared macOS/Linux edits are still made in their shared form and noted as shared; what is out is macOS as a deliverable, any macOS verification, signing/notarization, and L2-11's APFS half. Do not "fix macOS while you're in there" — an unverifiable edit to the APPLE branch is worse than none, because it looks tested §Λ-D4 phase-wide
Λ-D5 The Linux artifact is shipped, not developer-only (D6 = shipped), which promotes Λ-02 (a real optimized build) and Λ-08 (a documented install path) to must-fix. And a hard unlink prune is acceptable, with a platform-aware confirmation string saying so §Λ-D5 Λ-W2-T2, Λ-W4-T1, Λ-W5-T1
Λ-D6 The X11 editor is real work and is sequenced last. Not a separate ruling — the direct consequence of D1+D2+D3. Everything before it ships something; halting Λ-W8 still leaves a shipped extension and a loading, processing, generic-UI instrument §Λ-D6 the wave order itself

Open forks — four, NONE ruled

Stated with the evidence for both sides at docs/product/linux-readiness.md §"Open forks — Daniel's". This is the one phase in this plan with unanswered [Daniel]-class questions; see "Decision state" above for how that reconciles against the plan-wide claim.

Fork Question Recommendation What it blocks
Λ-F1 Does CI get built in this phase, and on what runner? (audit D4) No recommendation — genuinely a resourcing call. For: two toolchains and one is not on the developer's machine, so every Windows-only commit becomes a latent Linux regression. Against: CI is infrastructure, its value is highest after the first Linux build works, and a runner forces Λ-F4 immediately Nothing. Decides only whether Λ-W5-T1 writes a pipeline paragraph or a "deferred to dev-ops" one
Λ-F2 The dialog-resource route: resource-id-0 (SWELL's documented escape hatch, swell-functions.h:606608; deletes the whole resgen pipeline from non-Windows builds) or wire up resgen properly (PHP, add_custom_command, an include-shim TU — the M end of Λ-01's band)? (audit D7) No clean recommendation; the asymmetry neither audit stated is the reason it is worth ruling. Route A is cheaper and structurally simpler and stakes the panel's file-drop ingest on an unverified ChildWindowFromPoint descent, because with no resource there is no WS_EX_ACCEPTFILES bit to set (L2-07). Route B can set that bit explicitly. A third option, if the cheap route is wanted without the exposure: take Route A and make the file-drop check a hard gate in Λ-W3's sweep, with Route B as an additive follow-up track rather than a rewrite — it spends a second Linux session in the bad case Λ-W2-T3's dispatch — the ONLY fork here that gates a track. Unruled, that track slips to Λ-W4 and Λ-W3's sweep splits into two Linux sessions
Λ-F3 Does "copy-only is structural" survive as a shipped invariant? (T2 [Daniel] 4) Keep the invariant, scope the enforcement claim to Windows — "structurally enforced on Windows via DoDragDrop's copy-only mask; advertised, not enforced, on SWELL, which takes no effect mask." The real counter: an invariant one platform cannot enforce is arguably not an invariant, and weakening the global wording would also correctly warn a Windows reader off relying on it in shared code Nothing. Λ-W4-T3 makes the edit either way; only the sentence changes. Answerable at implementation review
Λ-F4 What is the declared support floor? (new — the audits raised the inputs Λ-07, V8, V10, not the decision) Declare a narrow floor — current-stable-distro glibc, GCC ≥ 9, x86_64-linux only — and widen it on request. Counter: REAPER's Linux reputation is partly built on modest and non-x86 hardware, aarch64 is no longer exotic, and adding it later means a second bundle directory and a second verification pass on every release The ship wave, not a dispatch. Non-gating for Λ-W2 (a recorded floor can be widened); gating for Λ-W5-T1 — a shipped artifact has to say what it runs on. Also feeds Λ-W6-T1's bundle directory set and Λ-F1's runner image

Phase-Λ acceptance criteria

These bind every track in this phase, in addition to the plan-wide set above.

  • Windows behaviour does not change. Every track carries that criterion explicitly. This is a port; a port that improves Windows by accident has also changed Windows by accident.
  • [verify — Linux] is a status, not a decoration. A criterion carrying it is discharged by a recorded observation in Λ-W3's verification record — never by inspection, never by "should work". A track whose Linux criteria are all unrun is not landed; it is authored.
  • No vendored file is patched. git status under vendor/ stays clean through the whole phase, including the SWELL bootstrap — which is precisely the constraint that shapes it.
  • No new third-party dependency surface. Λ-D2 rejected route B3b for this reason: no vendored SWELL build, no GDK/GTK3, no FreeType, no Fontconfig, no OpenGL, no pkg-config in this tree. A track that finds itself reaching for one of those has drifted and stops.
  • Shared macOS/Linux edits are made in shared form and labelled shared (Λ-D4). Λ-01, Λ-03, Λ-04, Λ-07, Λ-09 and L2-09 are all shared by the audits' own marking; an #ifdef _WIN32 / #else that is right for both costs nothing and needs no mac. No macOS verification is claimed for any of them.
  • core/ stays pure and stays platform-neutral. Every #include under src/core/** is a core/ sibling, one of 26 standard headers, or the generated version_generated.h (T2 §1.1) — the audits verified this rather than assuming it, and no Λ track may be the one that breaks it. The one platform fork in the whole directory (capture_paths.cpp:1820, the Windows case-fold) is already correct for Linux with both branches asserted by tests/test_capture_paths.cpp.
  • Every pure module gets a <module>_tests target that runs without REAPER or a DAW. Λ-W2-T4 is the phase's proof that this is not ceremonial: it is a core/-only fix, fully testable on the current Windows box, and it lands before any Linux session.
  • The ~600-line ceiling and the structural heuristics bind unchanged. Λ-W8-T1 is the track most likely to strain them; its own criteria name the seam vocabulary to reuse.

Performance posture. No named hot path is touched by any track in this phase — not the peaks envelope compute, not audition, not the realtime-capture tick's single-pointer-test idle fast path, not the instrument's process(). Two consequences are stated as criteria rather than left implicit: nothing is added to process() (no dlopen, no probe, no platform branch on the per-voice-per-sample path — the SWELL availability probe is computed once, lazily, off the audio thread), and Λ-W2-T4's locale fix stays on the JSON/persist path, which root CLAUDE.md already declares off all hot paths. Λ-02 is the phase's one genuine performance item and it runs the other way: today a Linux build carries no -O flag at all, on a tree whose peaks path is documented as presuming an optimizing build.

The non-REAPER-host safety contract

Λ-D3 stated as something a person can check. This is an observable contract, and it is the specification the two instrument waves are graded against — the full argument is docs/product/linux-readiness.md §"The non-REAPER-host safety criterion", which a brief for Λ-W6-T2, Λ-W7-T1 or Λ-W8-T1 must be written against.

The finding that makes it urgent, and it is a fact rather than a risk. The vendored vendor/WDL/WDL/swell/swell-modstub-generic.cpp declares a file-scope static at :125, so its constructor runs when our .so is dlopened — i.e. during the host's plugin scan — and :102 calls exit(2) when dlopen of libSwell.so fails, :117 exit(1) on an incomplete API table. Compiling the vendored SWELL_LOAD_SWELL_DYLIB path unmodified therefore means a Bitwig or Ardour plugin scan on a machine without libSwell.so terminates the host process — a killed DAW mid-scan, with the user's session. The mitigating detail that shapes the fix: doinit substitutes a zero-returning dummyFunc for each unresolved name, so a partial table degrades rather than crashes. A partial load is survivable; exit() is not.

In any Linux host, with or without libSwell.so: the module scans and enumerates its one class with no crash, hang, process exit or blacklist entry; it instantiates, produces audio, plays MIDI, and round-trips component state byte-identically with the Windows build; when no usable platform surface exists isPlatformTypeSupported returns kResultFalse for every type — including kPlatformTypeX11EmbedWindowID — and createView(kEditor) returns nullptr, never a view that then fails to attach and never one that draws nothing; the host falls back to its generic parameter UI; exactly one diagnostic line per process names why the editor is unavailable, through the SDK's logging or stderr, never a modal; teardown crashes nothing and leaves no partially-initialised SWELL table reachable.

Verified with a harness that is already on disk and that neither audit named, because neither swept the SDK's samples/ tree: vendor/vst3sdk/public.sdk/samples/vst-hosting/validator/ is a scriptable, headless, REAPER-free host, under public.sdk/ and therefore pulled by the documented narrow submodule init. The four checks, all [verify — Linux]: validator completes with no libSwell.so on the filesystem (the direct negation of the exit(2) finding, and the load-bearing one); a real Ardour or Bitwig scan reaching browser, instantiation and generic UI; a REAPER-on-Linux load where the editor opens; and a negative control — rename libSwell.so beside a Linux REAPER and confirm the generic-UI fallback plus the single diagnostic line.

Cross-phase boundary — Γ-W4-T1, and the frozen parameter table

Λ must never register a VST3 parameter. Γ-W4-T1's ParamID table is FOREVER-FROZEN from the moment it ships, on the same footing as the command-id strings and the class UIDs. A parameter minted in Λ to make a Linux fallback look better would collide with a table Λ does not own. This is a hard boundary and it is the only Γ↔Λ interaction that could actually go wrong.

Neither phase blocks the other. The host's fallback for a plug-in reporting no usable editor is its generic parameter UI, and today the plugin registers zero parameters — so "no editor" currently degrades to nothing rather than to controls. Λ-W6-T2's acceptance criteria are written to pass with zero parameters registered: an empty generic UI is a pass. Once Γ-W4-T1 lands its 44 derived parameters the identical Λ code path degrades to a usable generic UI instead — a better result, not a different criterion. Λ assumes no schedule for Γ; a Λ track needing a Γ fact reads dev at dispatch time.

Concurrency. Λ is the widest phase in this plan by file surface, and it is disjoint from the others by kind rather than by directory: its edits are platform guards, CMake, docs and one new shell/instrument/ TU. Γ owns core/instrument/ + shell/instrument/ sources (Λ-W6…Λ-W8 own that directory's CMake, its CLAUDE.md files, and editor_platform's non-Windows branch); Ε lands in the new core/package/ + shell/package/; Ρ touches core/capture, one new shell/capture TU, panel_input.cpp and main.cpp. The genuine adjacency to watch is CMake: Ε appends two add_subdirectory lines to the root CMakeLists.txt while Λ-W2-T2 owns that file's language and target settings — append-only lines against property blocks, textual adjacency rather than semantic contention. Λ's own shared files are named in the wave sections below.


Λ-W1 — The audits (complete)

Two tracks, both landed: T1 build-toolchain-audit (Λ-01…Λ-10, V1…V10, D1…D7) and T2 source-runtime-audit (L2-01…L2-12). Recorded so the wave numbering matches the audit filenames and branch names. Nothing to re-spec; nothing to dispatch.


Λ-W2 — Make it buildable, and make it honest

Depends on: nothing. Four tracks, disjoint at the file level. Three are authored blind against the audits and verified in Λ-W3; T4 is the one track fully verifiable on the current Windows box.

Track Owns
T1 linux-compile-blockers two source blockers + main.cpp's API-load failure branch
T2 toolchain-floor the CMake files' language, property and platform blocks
T3 panel-dialog-resource panel_window.cpp's dialog creation, resource.rc / resource.hgated on Λ-F2
T4 locale-independent-numerics the number codec in four core/ TUs

Shared file in the wave, named rather than discovered at merge: src/app/CMakeLists.txt — T2 takes the property and platform blocks, T3 takes one target_sources line under the resgen route only. Disjoint regions; whichever lands second rebases. The wave's severity question is dissolved rather than adjudicated: T2 grades L2-03 a Blocker on failure-mode quality with no direct evidence and L2-04 a Major with a confirmed mechanism, and flags its own inconsistency — both land here, in different tracks, so the relative grade never has to be settled.

Λ-W2-T1 — linux-compile-blockers

Goal. The extension compiles and links under GCC/Clang, and when it refuses to load it says why instead of vanishing.

Spec: docs/product/linux-readiness.md §Λ-W2-T1; findings L2-01, L2-02, L2-03.

Surface boundary — owns: src/shell/panel/draw_kit.cpp (loadFont, :7077), src/shell/actions/instrument_drop_win.cpp (writeTempPreset, :5061), src/app/main.cpp (the REAPERAPI_LoadAPI failure branch only). Does not own: any CMakeLists.txt, panel_window.cpp, or any core/ file.

Behavior.

  • FF_DONTCARE (L2-01). draw_kit.cpp:73 passes DEFAULT_PITCH | FF_DONTCARE to CreateFont; the symbol has zero occurrences anywhere in vendor/WDL/, and the file is not platform-guarded, only its include is. draw_kit links into both modules, so nothing builds until this is fixed. Drop the term or define it locally in the non-Windows include branch. Do not add windows.h — L2-01's stated direction. The family bits are advisory to Windows' font mapper and meaningless to fontconfig.
  • GetCurrentProcessId() (L2-02). instrument_drop_win.cpp:59 calls it with no platform branch anywhere in the TU; SWELL exports GetCurrentThreadId and not this. The PID exists only to keep two concurrent REAPER instances from colliding in the shared temp dir, and the atomic counter at :52 already carries the intra-process half. Replace with a platform-neutral uniqueness source behind a guard.
  • The silent load failure (L2-03). Either switch main.cpp to REAPERAPI_MINIMAL plus an explicit WANT list — the pattern panel_window.cpp and panel_audition.cpp already use, and the honest inventory of what this extension actually needs — or keep the full load and print the failure count via rec->GetFunc("ShowConsoleMsg") before returning 0.

Acceptance criteria.

  • cmake -B build -S . -G Ninja && cmake --build build produces build/reaper_reasampler.so with no errors. [verify — Linux] = V1.
  • ctest --test-dir build --output-on-failure passes all 91 test targets, no -C flag needed on a single-config generator. [verify — Linux] = V2.
  • A deliberately misspelled WANT entry (or a forced non-zero failcnt) produces a visible REAPER console line naming the count, not a silent refusal.
  • Windows build and ctest unchanged.

Prerequisites. None. Discharges: L2-01, L2-02, L2-03; enables V1, V2, V3.

Λ-W2-T2 — toolchain-floor

Goal. The Linux build is optimized when asked, links what it uses, hides what it does not export, and reports diagnostics no one has seen yet.

Spec: docs/product/linux-readiness.md §Λ-W2-T2; findings Λ-02, Λ-03, Λ-04, Λ-05, Λ-07, Λ-09, Λ-10.

Surface boundary — owns: root CMakeLists.txt, src/app/CMakeLists.txt (the target property and platform blocks; NOT the source list — that is Λ-W4-T3's), cmake/reasampler_targets.cmake, src/shell/instrument/CMakeLists.txt (thread linkage only; the WIN32 gate is Λ-W6-T1's), README.md, and root CLAUDE.md §"Build and test" / §"Install / reload" / §"One-time submodule setup". Does not own: any .cpp or .h.

Behavior.

  • Λ-02 — root CMakeLists.txt:2830 is the complete list of language settings: no CMAKE_BUILD_TYPE, no CMAKE_CXX_FLAGS, no IPO/LTO, no target_compile_options anywhere in the tree. Default CMAKE_BUILD_TYPE when neither it nor CMAKE_CONFIGURATION_TYPES is set, and correct the docs' ship instruction: --config Release is accepted and ignored by Ninja and Make, and the README currently sends a Linux user to build/Release/, which does not exist there — the module lands at build/reaper_reasampler.so.
  • Λ-03CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN on both module targets. SWELL's own build already uses -fvisibility=hidden, and the symbols that must stay exported carry their own visibility("default") attributes (reaper_plugin.h's REAPER_PLUGIN_DLL_EXPORT, fplatform.h's SMTG_EXPORT_SYMBOL, swell-modstub-generic.cpp:135's SWELL_dllMain).
  • Λ-04find_package(Threads REQUIRED) + Threads::Threads. Correct on all three platforms, costs nothing on Windows.
  • Λ-09-Wall -Wextra and CMAKE_CXX_EXTENSIONS OFF. No -Werror in this change (the audit is explicit): the diagnostic-set size over this tree is not estimable from Windows. Separately -Wl,--no-undefined on the module targets, restoring the fail-at-link-time behaviour MSVC gives and GNU ld does not — or a recorded reason why SWELL_PROVIDED_BY_APP's function-pointer design makes the gap moot.
  • Λ-05 — pin the reaper_plugin.h../WDL/swell/swell.h include coincidence with a comment or an INTERFACE target carrying both include dirs as one unit. Invisible on Windows, load-bearing off it.
  • Λ-07 — record the compiler floor (Λ-F4's input), or add -lstdc++fs and document why.
  • Λ-10 — one sentence in the platform-support docs: on Linux vendor/vst3sdk is optional until Λ-W6, so git submodule update --init vendor/reaper-sdk vendor/WDL is the complete extension-only prerequisite.

Acceptance criteria.

  • compile_commands.json or a verbose build log shows an explicit -O flag on a bare cmake --build build. [verify — Linux].
  • nm -D --defined-only reaper_reasampler.so | grep -E 'ReaperPluginEntry|SWELL_dllMain' finds both after the visibility preset. [verify — Linux] = V6.
  • The extension links with Threads::Threads removed — proving the include-only pthread dependency needs no flag — or the symbol forcing it is named. [verify — Linux] = V5.
  • capture_paths_tests links without an explicit -lstdc++fs on the declared floor, or the flag is added and the floor documented. [verify — Linux] = V8.
  • The warning count from the first -Wall -Wextra build is recorded, not fixed, and handed to Λ-W4 as an input.
  • Windows build unchanged; --config Release still behaves as documented there.

Prerequisites. None; concurrent with T1, T3, T4. Discharges: Λ-02, Λ-03, Λ-04, Λ-05, Λ-07, Λ-09, Λ-10; enables V5, V6, V8.

Λ-W2-T3 — panel-dialog-resource (GATED on Λ-F2 — do not dispatch until it is ruled)

Goal. The docked bank panel opens on Linux, and if it ever fails to, it says so.

Spec: docs/product/linux-readiness.md §Λ-W2-T3 and §Λ-F2; findings Λ-01, L2-06, L2-07.

The defect. panel_window.cpp:135 is CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), …), which SWELL resolves out of a per-module registry populated by a resgen-generated source file that is not in the Linux target: src/app/CMakeLists.txt:97 has the target_sources line commented out (:86 for macOS). The registry head stays null, SWELL_CreateDialog returns null, :137 returns, and the toggle action is a silent no-op — no console line, no Actions-list checkmark. Three defects stack inside the commented-out instructions themselves: the script named at :96 (mac_resgen.php) does not exist, the output filename is wrong, and the output is an #include-only artifact that cannot be a target_sources entry at all.

Surface boundary — owns: src/shell/panel/panel_window.cpp (the CreateDialogParam call at :135137, the dialog proc's platform contract, the drop-accept opt-in at :145150), src/resource.rc, src/resource.h, and — under the resgen route only — one target_sources line in src/app/CMakeLists.txt's else() branch plus a new include-shim TU. Does not own: any other panel TU, draw_kit, or any CMake target property. panel_window.cpp is deliberately not split across tracks: the L2-06 diagnostic and the Λ-01 resource route are the same function, and under the id-0 route the same line.

Behavior. Whichever route Λ-F2 picks, plus — unconditionally, and on both platforms rather than behind a guard — a one-line ShowConsoleMsg on the !g_panel.hwnd path naming the missing dialog resource (L2-06). That single line converts a mystery into a two-minute diagnosis and is worth having on Windows too. The fix is shared macOS/Linux either way (Λ-D4: made in shared form, verified on Linux only).

Acceptance criteria.

  • The panel toggle action docks a visible, LICE-drawn bank panel in a Linux REAPER. [verify — Linux] = V7.
  • With the resource deliberately unavailable, the toggle prints one console line rather than doing nothing. Verifiable on Windows by forcing the branch.
  • Dragging a WAV from the file manager onto the docked panel ingests it — or, if it does not, the failure is understood rather than mysterious. [verify — Linux], T2 §5 item 3. This criterion's difficulty depends on the Λ-F2 route and is the fork's substance.
  • Windows panel behaviour byte-for-byte unchanged.

Prerequisites. Λ-F2 must be ruled before dispatch. If it is not, this track slips to Λ-W4 and Λ-W3's sweep splits into a panel-independent half (run early) and a panel-dependent half (run after this lands) — a second Linux session, which is the cost of leaving the fork open. Discharges: Λ-01, L2-06, L2-07; enables V7.

Λ-W2-T4 — locale-independent-numerics

Goal. A persisted float round-trips identically regardless of process locale, so a bank index written on a comma-decimal machine is not written unparseable.

Spec: docs/product/linux-readiness.md §Λ-W2-T4; finding L2-04.

Why it is pulled forward, ahead of everything it looks like it should follow: it is the phase's one substantial fix that needs no Linux box at all — two writers and three readers, all in core/, all unit-testable on Windows today — and its failure mode is that the bank index is written unparseable and the project's whole bank is lost on reload. It should land before any Linux user saves a project.

Surface boundary — owns: src/core/json/json.cpp (the %.17g writer and the strtod reader), src/core/model/provenance.cpp (its own %.17g), src/core/wire/wire.cpp (Cursor::fieldDouble), src/core/capture/render_settings.cpp (the std::stod over REAPER's P_RAZOREDITS), and the corresponding tests/. Does not own: any shell TU, any CMake file, wav_codec (its byte-order handling is already explicit and correct).

Behavior. Make the number codec locale-independent at its two writers and three readers — std::to_chars/std::from_chars, or a std::locale::classic()-bound stream. Do not "fix" this by calling setlocale; an extension must not mutate the host's locale. Why it is not paranoia: on Windows the CRT starts in the "C" locale and nothing here calls setlocale, which is why it has never fired; on Linux SWELL's GDK backend calls gtk_init_check/gdk_init_check and never calls gtk_disable_setlocale, and any GTK or Qt plugin in the same process can do the same. The readers are honestly fail-closed — they require whole-token consumption — so the failure is "the field disappears", not "the field is silently wrong".

Acceptance criteria.

  • New pure tests pass on Windows today, under a forced comma-decimal LC_NUMERIC set inside the test. This is the one Λ-W2 track that does not wait for Λ-W3.
  • The bank index, view model, tracking ledger, provenance blob and tail setting all round-trip a fractional value under that forced locale.
  • Byte-for-byte identical output to today's writer under the "C" locale — this is a persisted format, and a changed representation is a compatibility event.
  • Nothing on a hot path is touched; the JSON/persist path is declared off all hot paths in root CLAUDE.md and stays there.

Prerequisites. None. Discharges: L2-04. Note: T2 §5 item 1 (read LC_NUMERIC inside a running REAPER-Linux process) stays in the Λ-W3 sweep, but only to record how urgent this was — the work is not gated on it.


Λ-W3 — First light, and the verification sweep

Depends on: Λ-W2. One track, deliberately — this is a person at a Linux box working a checklist where each answer reprices the next; splitting it across specialists buys no concurrency and loses the thread. Precedent: Ρ-W1 and Γ-W4 are both single-track waves for the same reason.

Λ-W3-T1 — linux-verification-sweep

Goal. Discharge every acceptance criterion Λ-W2 could not check from Windows, answer every [verify — Linux] item in both audits, and reprice the remaining waves against what is actually true.

Spec: docs/product/linux-readiness.md §Λ-W3-T1 and §"Why this sequence".

Surface boundary — owns: no source file and no CMake file. Its deliverable is a verification record at docs/product/audit-notes/lambda-w3-verification.md, one entry per item: the exact check run, the observed result, and what it changes. Any fix the sweep motivates is filed to Λ-W4, not made here. The one exception: a defect that blocks further sweeping (the build does not link at all) is fixed in place and recorded as a deviation, because the alternative is a wasted session.

Behavior — the sweep set.

From Items
T1 V1 compile, V2 ctest, V3 does REAPER's Linux build call SWELL_dllMain and populate the API table, V4 where UserPlugins/ actually is and whether reaper_*.so is the right glob, V5 pthread link flag, V6 visibility vs. the two exported symbols, V7 the docked panel, V8 -lstdc++fs, V9 does libSwell.so sit beside REAPER's executable, V10 which uname -m values the VST3 bundle must carry
T2 process LC_NUMERIC; REAPERAPI_LoadAPI's actual return value and the name of any gap; panel file-drop routing; which SWELL GDI/locale build REAPER ships; fontconfig's substitution for "Consolas"; SWELL_InitiateDragDropOfFileList acceptance by common targets, and whether its 500 ms no-motion timeout cancels a slow gesture; prune against an in-use file
New the -Wall -Wextra diagnostic set, counted and categorised; whether -Wl,--no-undefined links clean or names an undefined set; whether the four-TU LICE slice links without lice_colorspace.cpp (T1 §3 leaves this an unreconciled inference)

Three items are load-bearing beyond their own answer and must be run FIRST: V1 (nothing else is observable until it passes); V9 (a negative answer voids Λ-D2's route and needs a Daniel re-ruling before Λ-W6 is dispatched — the entire dlopen-REAPER's-SWELL design rests on it, and it is a one-line ls); and the REAPERAPI_LoadAPI count (a non-zero result promotes L2-03 from a diagnostic to a real Blocker and names the gap).

Acceptance criteria.

  • Every item above has a recorded answer or an explicit "could not determine, because X". A blank is a failure of this track, not a deferral.
  • Λ-W2's four tracks each have their [verify — Linux] criteria marked discharged or failed, by name.
  • Λ-W4 and Λ-W5's scope is restated against the answers, with any effort band that moved called out. The audits' bands are provisional by their own statement; this is where they stop being.
  • Λ-W6's prerequisites (V9, V10) are answered, or Λ-W6 is explicitly blocked pending a Daniel re-ruling on Λ-D2.

Prerequisites. Λ-W2-T1, T2, T4. T3 if Λ-F2 was ruled — otherwise the panel-dependent items (V7, file-drop routing, any font check needing a rendered panel) defer to a second session and that deferral is recorded. Discharges: V1V10 and T2 §5's seven items, as answers rather than as fixes.


Λ-W4 — Correctness and safety, repriced

Depends on: Λ-W3-T1. Three tracks, disjoint by directory. Everything here is known work whose size the sweep may have moved.

Λ-W4-T1 — prune-deletion-safety

Goal. Prune on Linux deletes only what it means to, tells the truth about what it reclaimed, and tells the user the deletion is permanent.

Spec: docs/product/linux-readiness.md §Λ-W4-T1; finding L2-05 (both halves) plus the symlink row of T2 §4.

Surface boundary — owns: src/shell/persist/prune_fs.cpp (the deletion authority's non-Windows branch and the reclaim scan) and the prune confirmation text wherever it is composed (shell/actions/prune_action / shell/panel/panel_bank_ops). Does not own: core/reclaim/'s orphan computation (pure, portable, unaffected), or any other persist TU.

Behavior.

  • The confirmation string becomes platform-aware (Λ-D5). On Linux it states the deletion is permanent and there is no Recycle Bin; on Windows it says what it says today. Same code path, one platform-dependent phrase — not a second dialog.
  • The dead "locked" branch is reckoned with. if (ec) return false; // real failure (locked/permission) -> skip encodes Windows file-sharing semantics; on Linux unlink of an open file succeeds, the audio keeps playing from the open fd, and the bytes are gone when it closes — so the branch never fires. Either it is documented as Windows-only in place, or the Linux path acquires an equivalent guard. What it must not do is stay silently asymmetric: the recovery floor root CLAUDE.md §"The resample bake" relies on ("the superseded file survives on disk until a prune reclaims it") otherwise has nothing under it on Linux. This half is not covered by Λ-D5's trash ruling — it is not a trash question.
  • The symlink hazard is fixed. fs::directory_iterator + is_regular_file() follows symlinks under C++17; size is read from the target via file_size() but fs::remove deletes the link, not the target — so prune reports N bytes reclaimed and reclaims zero. Symlinked media folders are far more idiomatic on Linux than on Windows. This is a reporting lie, not a cosmetic issue.

Acceptance criteria.

  • A Linux prune of a bank file currently playing behaves as recorded in Λ-W3's sweep, and the behaviour matches what the confirmation promised. [verify — Linux].
  • A symlinked bank file is either skipped or deleted with its target, and the reclaimed-byte figure matches what actually left the disk in both cases. Unit-testable for the computation; [verify — Linux] for the filesystem half.
  • The Windows path — SHFileOperationW + FOF_ALLOWUNDO, Recycle-Bin recoverable — is bit-for-bit unchanged.
  • Prune remains the only file-deletion path in the system (plan-wide product invariant).

Prerequisites. Λ-W3-T1 (the in-use-file check). Discharges: L2-05 both halves, and the symlink row of T2 §4.

Λ-W4-T2 — linux-font-faces

Goal. The kit asks for faces that exist on a stock Linux distro, so type is chosen rather than substituted.

Spec: docs/product/linux-readiness.md §Λ-W4-T2 and §"Calls made here"; finding L2-09. This is a product-designer call, not a fork: silent fontconfig substitution is not acceptable because the kit's type is part of a deliberate visual identity (docs/product/visual-design-language.md), and the palette-role discipline exists precisely so a visual direction is a single-file change rather than an emergent property of the host. The cost is five string literals. Contradict it in review with an argument.

Surface boundary — owns: src/shell/panel/draw_kit.cpp's five loadFont call sites (:154158) and the face constants in draw_kit.h. Does not own: loadFont itself beyond the literals, the palette, any geometry, or the two WCAG static_asserts — which are on pixel height and weight, not on the face, and hold regardless.

Behavior. A platform face list at the five call sites: DejaVu Sans / DejaVu Sans Mono as the Linux defaults, following SWELL's own no-fontconfig fallback list (LiberationSans/DejaVuSans, LiberationMono/DejaVuSansMono) as precedent. One code path, shared with macOS's eventual San Francisco/Menlo (Λ-D4: made in shared form, not verified). The subtlety to carry into the work: draw_kit.cpp:74's if (!hf) return guard does not catch this failure mode — SWELL's CreateFont always returns a non-null handle even when the face never resolved, recording the failure as a null typedata internally. A wrong or missing face is not observable at the call site, only in the rendering.

Acceptance criteria.

  • Every kit string renders in the intended face on a stock distro, and the numeric readouts are tabular. [verify — Linux].
  • Windows renders Segoe UI and Consolas exactly as today.
  • Row heights, ellipsis points and label truncation are unchanged on Windows; on Linux they are measured rather than assumed correct.

Prerequisites. Λ-W3-T1's fontconfig answer (T2 §5 item 5), which decides whether this is cosmetic or a readability regression. Discharges: L2-09.

Λ-W4-T3 — source-partition-and-invariants

Goal. The build's source list stops relying on every TU's own #ifdef discipline, and the invariants Linux weakens are stated where a reviewer will read them.

Spec: docs/product/linux-readiness.md §Λ-W4-T3 and §Λ-F3; findings Λ-06, L2-10, L2-11.

Surface boundary — owns: the target_sources list in src/app/CMakeLists.txt:851 (the list; the property blocks are Λ-W2-T2's), any new platform-sibling TU the sweep showed was needed, src/shell/actions/drag_out_win.h's invariant comment, and the corresponding src/shell/**/CLAUDE.md invariant passages. Does not own: any behaviour change in a shipped code path.

Behavior.

  • Λ-06 — partition the source list where the Λ-W3 diagnostic set says a TU needs it. drag_out_win.cpp is the model (a real #ifdef _WIN32 / #else split); arrange_drop_win.cpp and instrument_drop_win.cpp are _win-suffixed for the surface they serve, not for a platform dependency, and the audits found them portable by inspection — confirm against the actual compile rather than re-inspecting.
  • L2-10 — the copy-only invariant. Λ-F3 rules the wording; this track makes the edit either way. Make drag_out_win.h:711 the doc a Linux reviewer is pointed at, and treat "MOVE is structurally impossible" as a Windows-scoped claim: SWELL's file-list drag takes no effect mask, so nothing at the API level forbids a target from treating the drag as a move.
  • L2-11 — no Linux action. If the predicate is touched at all it becomes "case-insensitive filesystem", not "Windows" — but Λ-D4 puts macOS out, so the right move is a comment recording the known macOS defect, not a speculative fix.

Acceptance criteria.

  • No TU compiles on Linux only because of an #ifdef that happens to be complete; every platform-specific TU is either partitioned in CMake or carries a deliberate, commented guard.
  • The copy-only invariant's text says the same thing in drag_out_win.h, the owning CLAUDE.md, and any spec text that cites it.
  • Zero behaviour change on Windows.

Prerequisites. Λ-W3-T1. Λ-F3 for the L2-10 wording only — non-gating: the track can land the partition and leave the sentence for a follow-up. Discharges: Λ-06, L2-10, L2-11's Linux half.


Λ-W5 — Ship the extension

Depends on: Λ-W3-T1 (V4) and all three Λ-W4 tracks — shipping means the correctness fixes are in. One track. Gated on Λ-F4 (a shipped artifact has to say what it runs on).

Λ-W5-T1 — linux-packaging-and-install

Goal. A Linux user can install a correctly-built reaper_reasampler.so by following a document, and a pipeline can install it by following a rule.

Spec: docs/product/linux-readiness.md §Λ-W5-T1; finding Λ-08 plus Λ-02's shipped half.

Surface boundary — owns: an install() rule in src/app/CMakeLists.txtthe first in the treeREADME.md's platform-support and install sections, root CLAUDE.md §"Install / reload", and docs/product/versioning-and-release.md's artifact and pipeline paragraphs. Does not own: any source file, the toolchain properties (Λ-W2-T2's), or CI (Λ-F1).

Behavior.

  • Document the Linux UserPlugins/ root as V4 actually found it, and the single-config-generator output path (build/reaper_reasampler.so) that no doc currently names.
  • Add the install() rule for both channels. The channel fork is platform-independent by construction — REASAMPLER_CHANNEL threads through configure_file into names only — so reaper_reasampler_beta.so needs no separate mechanism, only a separate destination check.
  • Amend versioning-and-release.md, which already promises three platform artifacts per channel per release and mentions no Linux install path, no signing and no CI. Λ-D4 removes macOS from the near-term promise: that document should say two platform artifacts per channel for now, with macOS named as deferred rather than silently dropped.
  • The beta channel on Linux is uncosted in both audits. Almost certainly free — the fork is name-only — but "almost certainly" is not a ship criterion. Build and install both channels side by side once.
  • Λ-F1 decides only which paragraph gets written here — a pipeline paragraph, or an explicit "deferred to dev-ops". Until it is ruled, Λ ships nothing that presumes a runner.

Acceptance criteria.

  • A clean-machine walkthrough following only the README: clone, narrow submodule init, configure, build, install, restart REAPER, actions present, panel docks. [verify — Linux].
  • reaper_reasampler.so and reaper_reasampler_beta.so coexist in one REAPER with separate ext-state namespaces, command ids and dock idents. [verify — Linux].
  • The installed binary shows an explicit -O flag in its build log — Λ-02's criterion, re-checked on the artifact that actually ships.
  • versioning-and-release.md no longer promises an artifact this phase does not produce.
  • The declared support floor (Λ-F4) appears in the README and matches what was built.

Prerequisites. Λ-W3-T1 (V4), Λ-W4 (all three tracks), Λ-F4 ruled. Discharges: Λ-08, and Λ-02's shipped half.


Λ-W6 — The instrument module, and the host-safety contract

Depends on: Λ-W2-T2 (thread linkage, visibility, warnings) and Λ-W3-T1 (V10, plus V9 if Λ-D2 is to survive). Λ-W6 may run CONCURRENTLY with Λ-W4 and Λ-W5 — it touches src/shell/instrument/ and that directory's CMake and CLAUDE.md files plus src/core/instrument/CLAUDE.md, none of which Λ-W4 or Λ-W5 opens; the only reason to serialise is attention, not contention.

Two tracks — one build + docs, one source — and they are T1 before T2 — serial, the same shape this plan already records for Γ-W3. They are file-disjoint but not order-free: there must be a module before it can refuse to show a view.

At the end of this wave the Linux instrument loads in any host, processes audio, and has NO EDITOR AT ALL — a defined, verifiable, shippable state, not a half-done one.

Λ-W6-T1 — vst-linux-module

Goal. reasampler_9000.vst3 configures, builds and installs on Linux as the directory bundle a Linux host expects, and the decision that forbade it is reversed in writing.

Spec: docs/product/linux-readiness.md §Λ-W6-T1; audit findings B1, B2, B5.

Surface boundary — owns: src/shell/instrument/CMakeLists.txt (the gate, the entry-point source selection, the bundle POST_BUILD and install rule, SWELL_PROVIDED_BY_APP, the modstub TU) and the three D5 passages in src/core/instrument/CLAUDE.md, src/shell/instrument/CLAUDE.md and src/shell/panel/CLAUDE.md. Does not own: any .cpp or .h under shell/instrument/.

Behavior.

  • B5 first, as a documentation act. Rewrite the three D5 passages: the platform clause becomes "Windows and Linux; the editor is REAPER-hosted"; VST3-only and REAPER-only survive verbatim. src/core/instrument/CLAUDE.md carries it twice (Invariants and Non-goals), src/shell/instrument/CLAUDE.md once (Non-goals), and src/shell/panel/CLAUDE.md once (the font/GDI clause). Nothing else in this wave may land before this does — the invariant files are what a future implementer reads.
  • B1 — swap public.sdk/source/main/dllmain.cpp for linuxmain.cpp on Linux. dllmain includes <windows.h> with no SMTG_OS_* guard; linuxmain.cpp exports ModuleEntry and ModuleExit, both mandatory — the SDK's own loader refuses the module without either. Both files are already vendored; this is a source swap plus a platform if().
  • Split the WIN32 AND EXISTS conjunction at src/shell/instrument/CMakeLists.txt:9. Today it is a conjunction, so a Linux configure silently omits reasampler_vst even with the submodule slice fully initialised. The EXISTS half stays — a fresh clone with no VST3 slice must still configure — and the WIN32 half becomes a Windows-or-Linux predicate.
  • B2 — the artifact becomes a directory bundle: reasampler_9000.vst3/Contents/<uname -m>-linux/reasampler_9000.so, per V10's answer and Λ-F4's floor. Contents/Resources/moduleinfo.json is optional — the SDK's getModuleInfoPath returns empty when absent rather than failing — so do not author one. Install roots: $HOME/.vst3/, /usr/lib/vst3/, /usr/local/lib/vst3/, $APPFOLDER/vst3/.
  • SWELL_PROVIDED_BY_APP + the modstub TU are added to this target in the default (non-SWELL_LOAD_SWELL_DYLIB) branch — the same branch the extension already uses (src/app/CMakeLists.txt:9192). The whole modstub file is inside #ifdef SWELL_PROVIDED_BY_APP, so the VST3 target must define that symbol; today it does not. The dlopen itself is Λ-W7's; what this track lands is the compiled-in, inert table plus the exported SWELL_dllMain.
  • The channel fork applies: reasampler_9000_beta.vst3 with its own class UID. The UID pair is FOREVER-FROZEN and must not change — a Linux build is a new platform, not a new identity, and a saved project rebinds by UID.

Acceptance criteria.

  • A Linux configure produces the reasampler_vst target; a Windows configure is unchanged.
  • The built bundle's directory shape matches what the SDK's module_linux.cpp opens, and the SDK's own validator loads it. [verify — Linux].
  • Both channels build and install side by side, and the two class UIDs are byte-identical to the Windows build's.
  • The three CLAUDE.md files no longer claim Windows-only, and still claim VST3-only and REAPER-only.
  • No VST3 parameter is registered by this track or any other in this phase — see the Γ-W4-T1 boundary above.

Prerequisites. Λ-W2-T2, Λ-W3-T1 (V10; V9 if Λ-D2 is to survive). Discharges: B1, B2, B5; and Λ-10's "vst3sdk is optional on Linux" caveat becomes conditional.

Λ-W6-T2 — vst-host-safety-contract

Goal. The plugin's editor surface refuses cleanly on every Linux host, so that adding a real editor later is a change of branch taken and not a change of contract.

Spec: docs/product/linux-readiness.md §Λ-W6-T2 and §"The non-REAPER-host safety criterion"; Λ-D3, L2-08's fallback half.

Surface boundary — owns: src/shell/instrument/editor_platform.cpp (the isPlatformTypeSupported / createView decision and the non-Windows stubs), reasampler_processor.cpp's createView site, reasampler_embed.cpp's REAPER_FXEMBED_WM_IS_SUPPORTED, and a new availability-probe seam under shell/instrument/. Does not own: any CMake file, the paint or input families (untouched — they stay whole-region-guarded), or any pure core/instrument/ module.

Behavior.

  • The editor's availability becomes a tri-state probe — untried / available / unavailable — computed once, lazily, off the audio thread. In this wave the Linux answer is unconditionally unavailable; Λ-W7 gives it a real computation without changing a single call site.
  • isPlatformTypeSupported returns kResultFalse for every type when unavailable; createView(kEditor) returns nullptr. Never a view that fails to attach.
  • One diagnostic line per process, naming the reason. Never a modal.
  • reasampler_embed's REAPER_FXEMBED_WM_IS_SUPPORTED continues to return 0 off Windows. That is correct and stays correct — the TCP/MCP embed strip on Linux is an explicit non-goal of this phase.
  • The Windows path is not restructured to accommodate this. The probe is a Linux branch on an existing decision, not a new abstraction over both.

Acceptance criteria. The full observable contract above, verified by all four of its checks: validator with no libSwell.so present; a real Ardour or Bitwig scan; a REAPER-on-Linux load; and the renamed-libSwell.so negative control. [verify — Linux]. Plus:

  • Windows editor behaviour bit-for-bit unchanged — same window class, same wndProc, same CS_DBLCLKS fall-through.
  • Nothing added to process() — no dlopen, no probe, no branch on the per-voice-per-sample path.
  • The criteria pass with ZERO VST3 parameters registered. An empty generic UI is a pass; Γ-W4-T1 improves it and Λ must not.

Prerequisites. Λ-W6-T1 (there must be a module to load). Discharges: Λ-D3's Λ-W6 half; L2-08's fallback half.


Λ-W7 — The SWELL bootstrap

Depends on: Λ-W6-T1, Λ-W6-T2, and V9 answered affirmatively. If V9 is negative, this track does not exist and Λ-D2 needs a re-ruling — which is why V9 runs first in Λ-W3 rather than being discovered here.

Λ-W7-T1 — swell-dylib-bootstrap

Goal. The plugin acquires a working SWELL function table under REAPER on Linux, without the vendored stub's exit() behaviour and without vendoring SWELL.

Spec: docs/product/linux-readiness.md §Λ-W7-T1 and §"The implementation shape this obliges"; Λ-D2 (route B3a), audit finding B3.

Surface boundary — owns: a new src/shell/instrument/swell_bootstrap.{h,cpp}, the availability-probe computation Λ-W6-T2 left stubbed, and the SWELLAppMain-shaped callback. Does not own: any vendored file (nothing under vendor/ is patched), the CMake source list beyond adding one TU, or the editor.

Behavior — exactly the four-point shape in the product doc, and it is not a re-litigation of Λ-D2 but the specification of it.

  1. Never define SWELL_LOAD_SWELL_DYLIB. Compile swell-modstub-generic.cpp in its default branch, which exports SWELL_dllMain(hInst, callMode, GetFunc) and calls doinit on the pointer it is handed.
  2. Own the load. Resolve the host executable's directory, dlopen libSwell.so, dlsym SWELLAPI_GetFunc, dlsym and call SWELL_set_app_main, then call our own exported SWELL_dllMain(hinst, DLL_PROCESS_ATTACH, getfunc)using only exported surface, with every failure returning a recorded state instead of exiting. This also dissolves the SWELLAppMain-as-a-link-requirement noted in T1 B3a: we pass an app-main because we choose to, not because the linker demands one. [verify — Linux] whether a minimal app-main suffices for a plugin that only ever creates child windows inside a host-supplied X11 window.
  3. Probe the host, not just the library. A non-REAPER host has no SWELL message loop — REAPER's own is what pumps SWELL windows — so even a successful dlopen in Ardour would produce a window nothing drives. Gate on the host name via IHostApplication::getName() AND on the dlopen succeeding; either failing means no editor.
  4. Probe once, lazily, off the audio thread. Tri-state, computed on first createView and never recomputed. No dlopen from process(), no per-createView retry, no static-constructor work.

The failure taxonomy the probe must distinguish, because they need different messages: host is not REAPER / executable path unresolvable / libSwell.so not present at the resolved path / SWELLAPI_GetFunc missing or version-mismatched (the stub checks SWELLAPI_GetFunc(NULL)==(void*)0x100) / API table incomplete. The last is not fataldoinit substitutes a zero-returning dummyFunc per miss — and whether to accept a partial table or refuse is [propose at review], with "refuse if any name the editor actually calls is missing" as the starting proposal.

Acceptance criteria.

  • Under REAPER on Linux the probe reports available and the SWELL table resolves with zero misses. [verify — Linux].
  • Under validator with no libSwell.so anywhere, the probe reports unavailable, the process exits normally, and one diagnostic line is printed. This is the direct negation of the exit(2) finding and is the track's headline criterion. [verify — Linux].
  • Under Ardour or Bitwig with a libSwell.so reachable, the host-name gate still refuses — proving the gate is on the host, not only on the library. [verify — Linux].
  • No vendored file is modified; git status under vendor/ is clean.
  • Windows build unaffected — the whole TU is behind a platform guard.

Prerequisites. Λ-W6-T1, Λ-W6-T2, V9 affirmative. Discharges: B3 via route B3a; Λ-D3's runtime half.


Λ-W8 — The X11 editor

Depends on: Λ-W7-T1. One track, and it is the phase's only L-band source item (L2-08; T1 B4 costs it "build S, source L"). Λ-D6 sequences it last on purpose: halting this wave still leaves a shipped extension and a loading, processing, generic-UI instrument — a real product, not a stub.

Λ-W8-T1 — x11-embed-view

Goal. The ReaSampler 9000 editor opens, draws and responds under REAPER on Linux.

Spec: docs/product/linux-readiness.md §Λ-W8-T1; audit findings B4, L2-08.

Surface boundary — owns: a Linux sibling to src/shell/instrument/editor_platform.cpp (window creation and parenting, the run-loop timer, the event-driven input path), and the #ifdef _WIN32 region boundaries in reasampler_editor.h and the eight editor_input_* / editor_paint* TUs — boundaries only, not their contents. Does not own: draw_kit, any pure core/instrument/ module, any painter's drawing logic, instrument_bake, or the processor.

Behavior. The audits call this a new competence rather than a port. The build-side cost is nil — the Linux IIDs are already in the vendored slice (commoniids.cpp defines Linux::IEventHandler, Linux::ITimerHandler, Linux::IRunLoop under #if SMTG_OS_LINUX, and that file is already in the vst3_sdk source list). What must be written:

  • kPlatformTypeX11EmbedWindowID instead of kPlatformTypeHWND; attachedToParent receiving an X11 window id.
  • A Linux::IRunLoop-driven timer replacing SetTimer/WM_TIMERincluding the editor's sync tick, which the bake's arm-then-run discipline rides on.
  • An event-driven input path replacing the wndProc switch. RegisterClassW / CreateWindowExW / DefWindowProcW have no SWELL analogue at all — SWELL has no window-class model, only SWELL_CreateDialog and raw HWND__ construction.
  • Substitutions the audit already resolved by name: MoveWindowSetWindowPos; GetWindowLongPtr/SetWindowLongPtr → the non-Ptr forms returning LONG_PTR; GetKeyStateGetAsyncKeyState; TrackMouseEvent/WM_MOUSELEAVEnothing, so hover-leave needs its own derivation (the panel layer already documents the same gap); DragAcceptFiles/DragQueryFileW → the drop path Λ-W2-T3 settled for the panel.

A worked reference implementation is already on disk and neither audit named it. vendor/vst3sdk/public.sdk/samples/vst-hosting/editorhost/source/platform/linux/ contains window.cpp (returning {kPlatformTypeX11EmbedWindowID, …} and answering Linux::IRunLoop::iid from queryInterface), runloop.cpp, and irunloopimpl.h (a RunLoopImpl implementing registerEventHandler / registerTimer and their unregisters). It is the host side of the contract rather than the plug-in side, which makes it a precise specification of what our plug-in side must satisfy, and the documented narrow submodule init already pulls it. This does not shrink the L band — it is a reading input, not a library; it removes the "we are guessing at the contract" risk from the estimate.

Acceptance criteria.

  • The editor opens, draws every face identically to the Windows build (compare screenshots at the same window size), and every drag, click, wheel and keyboard interaction behaves the same. [verify — Linux].
  • Hover-leave is correct on every hover surface, without TrackMouseEvent.
  • The bake's arm-then-run tick fires under IRunLoop, and a bake completes end-to-end on Linux: staged file, extension action invoked over the VST3 host bridge, outcome read back, adopt and reset. [verify — Linux].
  • Λ-W6-T2's contract still holds — a non-REAPER host still gets nullptr from createView. Re-run all four checks; this is a regression criterion, not a new one.
  • Nothing added to process(); no dispatch added to any per-sample path.
  • Every new file lands under the ~600-line ceiling with a responsibility seam, not a bisection. The editor's existing band-axis split (_chrome / _waveform / _deck / _browse / _curve) is the seam vocabulary to reuse — the Linux platform TU is a sibling to editor_platform, per L2-08's own direction: "it needs a sibling, not a rewrite."
  • Windows editor behaviour bit-for-bit unchanged.

Prerequisites. Λ-W7-T1. Discharges: B4, L2-08.


Shared files across Λ's waves, named rather than discovered at merge

All textual adjacency, not semantic contention, unless marked otherwise.

File Tracks Nature
src/app/CMakeLists.txt Λ-W2-T2 (property + platform blocks), Λ-W2-T3 (one target_sources line, resgen route only), Λ-W4-T3 (the source list), Λ-W5-T1 (the install() rule) Four disjoint regions of one file. Λ-W2-T2 and Λ-W2-T3 are the only pair in the same wave; one line each
src/shell/panel/draw_kit.cpp Λ-W2-T1 (loadFont's CreateFont args, :73), Λ-W4-T2 (the five call sites, :154158) Different waves
src/shell/instrument/CMakeLists.txt Λ-W2-T2 (thread linkage), Λ-W6-T1 (gate, entry point, bundle, install), Λ-W7-T1 (one added TU) Different waves
src/shell/panel/panel_window.cpp Λ-W2-T3 alone Deliberately not split. The L2-06 diagnostic and the Λ-01 resource route are the same function — under the id-0 route the same line. Splitting them would be semantic contention
src/shell/instrument/editor_platform.cpp Λ-W6-T2 (the refusal branch), Λ-W8-T1 (the real branch) Different waves; the second replaces the first's computation without touching its call sites

Traceability — all seventeen items

The check that nothing was dropped. Every row points at a track that exists above.

# Item (short) Phase-Wave-Track Worktree slug
1 Envelope editor: radio switch, curve dials, overlay recolor Θ-W3-T2 pth-w3-t2-staged-envelope-curves
2 MM preamp Filter — resonant HP/LP stage Θ-W1-T3 (DSP) + Θ-W2-T1 (integration) pth-w1-t3-filter-dsp-port, pth-w2-t1-filter-voice-path
3 Alternative Spline EGs Θ-W5-T1 pth-w5-t1-spline-egs
4 Bug: end-of-sample click, Trigger × Preserve Θ-W3-T2 pth-w3-t2-staged-envelope-curves
5 Bug: drag-out sometimes lands without audio Θ-W1-T2 pth-w1-t2-capture-handoff-bugs
6 Bug: FX-container drop loses the capture Θ-W1-T2 pth-w1-t2-capture-handoff-bugs
7 Stereo waveform shows both channels Θ-W2-T2 pth-w2-t2-stereo-waveform-lanes
8 Release anchoring; the Pitch AD becomes AHD Θ-W3-T2 pth-w3-t2-staged-envelope-curves
9 Loop points — regression + Gate loop-sustain Θ-W4-T1 pth-w4-t1-gate-loop-sustain
10 Knob/label sizing, ms units, double-click reset Θ-W6-T1 pth-w6-t1-legibility-and-antialiasing
11 Preview glyph; VELOCITY deck; bipolar curves Θ-W4-T2 pth-w4-t2-velocity-deck-and-bipolar-curves
12 Toolbar cleanup; full-width piano strip; tooltips Θ-W2-T3 pth-w2-t3-toolbar-and-piano-strip
13 Antialiased rendering audit for high-DPI Θ-W6-T1 pth-w6-t1-legibility-and-antialiasing
14 Trigger amp/filter fade → AHD consolidation Θ-W3-T2 pth-w3-t2-staged-envelope-curves
15 One-click in-sampler resample Ξ-W1-T2 (note model) + Ξ-W2-T1 (bake chain) + Ξ-W3-T1 (popup abandoned by ruling; window derives itself) pxi-w1-t2-note-program-model, pxi-w2-t1-resample-bake-chain, pxi-w3-t1-capture-signal-popup
16 Retire the zone mapping system Θ-W1-T1 pth-w1-t1-zone-retirement
17 Consolidate provenance/usage tracking Ξ-W1-T1 pxi-w1-t1-tracking-consolidation

Work in this plan that is not one of the seventeen

The table above is a completeness proof over TODO-1.0.md — every row points at a track, so nothing from the source was dropped. It is deliberately not an index of the plan: work that did not come from the source doc has no row, and inventing one would weaken the proof it exists to give.

  • Θ-W3-T1 — live-parameter-delivery (pth-w3-t1-live-parameter-delivery). Arose from Θ-W2-T1's implementation review, not from TODO-1.0.md. The first such track in this plan; see Θ-W7-T1 below for the second.
  • Θ-W7-T1 — arc-and-spline-aa (pth-w7-t1-arc-and-spline-aa). Opened after Θ-W6-T1 shipped, when Daniel found two rendering defects by eye in the editor — not from TODO-1.0.md, and not a track this plan originally scoped. The second such track in this plan today; if others appear, they belong on this list rather than in the table.
  • All of Phase Γ (pg-*). Ten tracks across four waves, from a direct interview with Daniel (2026-08-01) and his four later rulings the same day, not from TODO-1.0.md. Listed here as a block rather than per track, because the whole phase is outside the source doc; the product reasoning lives in docs/product/instrument-control-surface.md and the parameter system's in docs/product/parameter-automation.md §§610. Two docs/TODO.md entries are discharged by this phase, not deferred again: Γ-W3-T1 discharges the deck-rework entry (whose original "one row of taller decks with within-deck stacking" shape Daniel explicitly superseded), and Γ-W1-T1 discharges "Raise the stage-time ceiling above 2 s" (Γ-F3 reversed).
  • All of Phase Ψ (ppsi-*). Seven tracks across three waves, from a direct list of seven defects and refinements (Daniel, 2026-08-01), not from TODO-1.0.md. Listed here as a block, like Γ; unlike Γ it has no backing product doc — the seven are recorded verbatim in the phase header as its provenance (Ψ.1–Ψ.7), and the design content lives inline in its tracks. The seventh track, Ψ-W3-T1, is not one of the seven defects/refinements itself — it came from a review finding mid-phase; see the Phase Ψ section for detail.
  • Γ-W3-T2 bake-reset-amendment is a CORRECTION, not a feature, and belongs on this list for a different reason from the others: it exists only because Ξ-W2-T1 shipped ahead of this plan's sequencing claim. If more corrections of this shape appear, they belong here rather than in the table — the table is a completeness proof over TODO-1.0.md, and a correction has no source row to point at.
  • All of Phase Ε (pe-*). Six tracks across three waves, from a direct request (Daniel, 2026-08-02), not from TODO-1.0.md. Listed here as a block, like Γ and Ψ; the product reasoning lives in docs/product/bank-package.md. It supersedes nothing — neither docs/TODO.md nor docs/TODO-1.0.md records export, import, or a package format, so there is no deferred entry to absorb or contradict. Its three [Daniel]-class forks (Ε-F1/F2/F3) were opened and ruled the same day it was framed, so no track here is gated on a decision; see "Decision state" above.
  • All of Phase Ρ (pr-*). One track in one wave, from a direct request (Daniel, 2026-08-02), not from TODO-1.0.md; the product reasoning lives in docs/product/render-in-place.md. It supersedes nothing — a sweep of docs/TODO.md and docs/TODO-1.0.md for render.in.place|render to new track|preserve.source returns nothing. The smallest phase in this plan, deliberately: it is a thin third verb composed out of machinery that already exists, and the burden was on any new machinery to prove it unavoidable. Its three [Daniel]-class forks (Ρ-F1/F2/F3) were opened and ruled the same day it was framed, so the track here is not gated on a decision; see "Decision state" above. Ρ-F2 was the one that moved the spec — the result track goes to Arrange unconditionally rather than following the active mode, which is also the only reason the phase touches panel_input.cpp at all.
  • All of Phase Λ (pl-*). Thirteen pending tracks across seven waves (Λ-W2…Λ-W8), plus Λ-W1's two audit tracks, which are landed. From a direct request (Daniel, 2026-08-02), not from TODO-1.0.md; the product reasoning lives in docs/product/linux-readiness.md and the evidence in the two audit notes it cites. It supersedes nothing and corrects one standing promise rather than inheriting it: docs/product/versioning-and-release.md commits to three platform artifacts per channel per release, and Λ-D4 (macOS out) makes that two for now, with macOS named as deferred. Λ is the one phase in this plan with unanswered [Daniel]-class questions — four forks, none ruled, of which only Λ-F2 gates a dispatch and only Λ-F4 gates a wave; see "Decision state" above. It is also the one phase whose acceptance criteria cannot be checked from the current box at all, which is why Λ-W3 exists as a wave rather than as a checklist at the end.

Deliberate compressions

Recorded so a reader of TODO-1.0.md can see what this plan did to the source, rather than discovering it later:

  • Item 2 is split across two waves. The DSP port (Θ-W1-T3) is deliberately separated from the integration (Θ-W2-T1) so the external-input dependency on Daniel's Cortex-M4 code sits on a standalone, disjoint track instead of blocking a wave. Item 2's acceptance criteria are split accordingly — the range/resonance assertions land in W1-T3's tests, the audible/pipeline/deck criteria in W2-T1.
  • Item 2's filter envelope ships twice. W2-T1 ships it in the existing staged AHDSR shape; W3-T2 gives it the curve treatment and the mode-driven Gate→AHDSR / Trigger→AHD shape. This is deliberate: waiting would put the filter behind the whole envelope system.
  • Item 4 lands in Θ-W3, not Θ-W1. Its fix region is the region item 14 retires, and the only earlier instrument-side track owns the entire engine. Its acceptance gate is stated as the post-consolidation gate. It is not gated behind the whole editor chain — three waves of six — and the source doc itself requires re-verification under the surviving mechanism either way.
  • Item 11's filter velocity curve is split from item 2's filter velocity modulation. W2-T1 wires the modulation path (following the amp/pitch precedents); W4-T2 sets the curve's bipolar domain and default and homes its button. Neither track can do the other's half.
  • Item 15's undo/recovery is carried as a note, not a criterion — Daniel set it at exactly "a plus." The guaranteed recovery floor (the superseded file surviving until prune) is a criterion.
  • Nothing else was compressed. Every other item's behavior bullets and acceptance criteria are carried at full strength into the track that owns it.

Outline at a glance

Phase Θ — ReaSampler 9000: one parameter set, filter, shapeable envelopes, legible editor
  W1  Collapse and re-seam
      T1 zone-retirement ......................... 16
      T2 capture-handoff-bugs .................... 5, 6
      T3 filter-dsp-port ......................... 2 (DSP)      [needs Daniel's M4 code]
  W2  Filter in the voice path; waveform and chrome bands
      T1 filter-voice-path ....................... 2 (integration)
      T2 stereo-waveform-lanes ................... 7
      T3 toolbar-and-piano-strip ................. 12
  W3  Live parameters, then the staged envelope system      [T1 before T2 — serial]
      T1 live-parameter-delivery ................. (not one of the seventeen)
      T2 staged-envelope-curves .................. 1, 8, 14, 4
  W4  Loop sustain and the velocity deck
      T1 gate-loop-sustain ....................... 9
      T2 velocity-deck-and-bipolar-curves ........ 11
  W5  Spline EGs
      T1 spline-egs .............................. 3
  W6  Editor legibility pass
      T1 legibility-and-antialiasing ............. 10, 13
  W7  Arc-and-spline antialiasing fix
      T1 arc-and-spline-aa ....................... (not one of the seventeen)

Phase Ξ — The resample loop            (W1 concurrency-safe with Θ from Θ-W2 onward)
  W1  Consolidated tracking, and the programmed-note model
      T1 tracking-consolidation .................. 17
      T2 note-program-model ...................... 15 (model)
  W2  The bake chain            [ran AHEAD of Γ; its reset list is corrected by Γ-W3-T2]
      T1 resample-bake-chain ..................... 15 (chain)
  W3  The capture-signal popup      [popup abandoned by ruling; bake window derives itself]
      T1 capture-signal-popup .................... 15 (popup abandoned; window derives)

Phase Γ — The instrument's control surface      (none of the seventeen; ends with VST3 params)
  W1  Foundations — landed                   [seven tracks, disjoint by surface]
      T1 knob-interaction-law ....... modifiers + ONE taper module + reset bypass
                                      + 10 s ceiling + AHDSR schematic axis   [Ruling 2]
      T2 master-bus-audio ........... limiter + meter ballistics + dynamic PDC [rung 1]
      T3 contour-trace-curves ....... staged traces draw curved, knot on its trace
      T4 editor-floor-and-row-law ... floor 1190x680 + budget constants + row predicate
      T5 preserve-time-stretch ...... real stretcher      [measure-and-report gate]
      T6 exhaustive-switch gate on pure libraries ... /we4062, -Werror=switch on
                                      pure libraries   [no PLAN entry — see COMPLETED.md]
      T7 psola-preserve ............. PSOLA-aligned splice jump; period_detect
                                      [cadence closure — see COMPLETED.md]
  W2  New controls, and the overlay's marks  [2 tracks]
      T1 pitch-rate-deck ............ Rate + Pitch, Varisp/Presrv compounding  [rung 2]
      T2 loop-crossfade-ux .......... four-mark grammar; fade painted where it is heard
  W3  The reflow, and the bake correction    [2 tracks]
      T1 deck-reflow ................ two rows + double-height MASTER, by construction
      T2 bake-reset-amendment ....... the Xi correction Gamma owns  [needs Xi-W2-T1 on dev]
  W4  VST3 parameters                        [1 track]
      T1 vst3-parameter-set ......... 44 derived params, frozen id table     [Ruling 1]
                                      [rung 3 RESERVED, spent only if verify says so]
                                      [OPEN: Gamma-F7, the parameter order — Daniel]

  Resequenced 2026-08-01, three times: the reflow split canvas (W1-T4) from arrangement
  (W3-T1); preserve-time-stretch moved W4 -> W1-T5, retiring Rate's interim stand-in; then
  Ruling 1 added W4 and Ruling 2 grew W1-T1.
  Payload rungs are RELATIVE, not absolute. On this branch at 2026-08-01, the first is already
  spent — kParamsPayloadVersion == 15 (Γ-W1-T2, landed, above) — the second is the next free
  rung, v16 (Γ-W2-T1, stated there), and the third remains RESERVED as v17, spent only if
  Γ-W4-T1's storage verification forces it.
  Shared files, named: engine/CMakeLists.txt (W1-T2 | W1-T5), ui/CMakeLists.txt
  (W1-T1 | W1-T3), editor_session.cpp (W2-T1 | W2-T2) — all textual adjacency, not
  semantic contention. W3-T2's disjointness from W3-T1 is CONDITIONAL: confirm it against
  what Xi-W2-T1 shipped, and serialize behind T1 if it does not hold.

Phase Psi — The extension trust pass          (none of the seventeen; a direct list of seven)
  W1  Exact bounds, disciplined switches, reachable actions, resolved drops  [4 tracks]
      T1 capture-range-exactness ..... Psi.7   [opens with a DAW repro matrix]
      T2 mode-switch-discipline ...... Psi.2 + Psi.3   [one chokepoint: applyMode]
      T3 media-explorer-section ...... Psi.4   [BOTH sections; new FOREVER-STABLE id]
      T4 drop-target-resolution ...... Psi.5   [gesture law, not a patch]
  W2  Names and channels, over the settled render block   [gated on W1-T1's render block]
      T1 capture-naming .............. Psi.1   [+ the card shows the name]
      T2 mono-collapse ............... Psi.6   [lossless only; ingest excluded]

  Three invariant amendments are track deliverables: never-touch-solo (W1-T2, three
  files), the action-registration contract (W1-T3, root CLAUDE.md), channel-count-
  preserved (W2-T2, root CLAUDE.md:208).
  Shared files, named: panel_input.cpp (W1-T2 footer block | W1-T4 drag-arm block);
  capture.cpp + capture_realtime_finalize.cpp (W2-T1 naming lines | W2-T2 channel
  lines) — all textual adjacency, not semantic contention. main.cpp is W1-T3's
  exclusively.

Phase Epsilon — The bank package        (none of the seventeen; a direct request 2026-08-02)
  W1  The contract, the filesystem, and the ledger's new kind   [3 tracks, disjoint by dir]
      T1 package-format .............. core/package: framing + TWO version ints
                                       [E-F1 RULED: proprietary RSBK. No ZIP, no zlib]
      T2 package-fs-shell ............ shell/package: atomic write, streaming, pickers
                                       [ONE picker: REAPER GetUserFileName, mode 0/1]
      T3 import-origin-kind .......... OriginKind::PackageImport = 5, append-only
  W2  The two verbs                    [2 tracks; disjointness CONDITIONAL — see below]
      T1 bank-export ................. export_plan + verb + action; project untouched
      T2 bank-import ................. import_plan + verb + action + .rsbank drop
                                       [E-F2 RULED: always a NEW bank, auto-suffix, no
                                        merge. E-F3 RULED: REFUSE on degraded ledger]
  W3  The compatibility fixtures       [1 track]
      T1 package-compat-fixtures ..... frozen bytes prove BOTH version directions

  Version policy, both directions: older package in newer build ALWAYS imports (additive
  fields default, unknown keys skipped); newer package in older build REFUSES WHOLE with a
  three-part message (package needs / this build reads / writer semver). The gate is
  minReaderVersion <= kPackageFormatVersion — formatVersion is for the message, not the gate.
  All three E-forks were ruled 2026-08-02, the day the phase was framed: NO track here is
  gated on a decision. Import name collision = first free of seed, "seed 2", "seed 3", ...
  seed never re-parsed, fold is BankBook's own.
  Shared files, named: root CMakeLists.txt add_subdirectory list (W1-T1 | W1-T2, one
  append-only line each); main.cpp + the panel bank menu (W2-T1 | W2-T2, one registration
  line and one menu row each); bank_book.{h,cpp} is W2-T2's alone (one additive public const
  member). W1's three tracks are unconditionally disjoint; W2's two are
  textually adjacent only — serialize T2 behind T1 if zero contention is wanted.

Phase Rho — Render in place             (none of the seventeen; a direct request 2026-08-02)
  W1  The verb                                          [ONE track, deliberately]
      T1 render-in-place ............. render selected track -> new sibling track,
                                       item placed at the exact render position,
                                       colour + "Capture " name cloned, source -> Design,
                                       result track -> ARRANGE always. Never the bank.
                                       [R-F1 RULED: refuse multi-track, settled non-goal.
                                        R-F2 RULED: result track ALWAYS Arrange, never
                                        mode-following. R-F3 RULED: follow panel tail]

  The THIRD VERB. Capture = arrange -> bank (writes it). Placement = bank -> arrange
  (reads it). Render in place = arrange -> arrange (bank on neither side). That is why the
  load-bearing capture/placement rule survives it; the full argument is in the phase header
  and in docs/product/render-in-place.md.
  Structural boundary, not a convention: the render destination reaches the backend as a
  TWO-VALUED ENUM (Bank | ProjectMedia), never a caller-supplied path — so no Rho caller can
  name the bank folder. A renderDir string on CaptureRequest IS the drift.
  Genuinely new: ONE pure sibling-placement function (folder-parent and last-in-folder are
  both audibly wrong if inserted at srcIdx+1), an idempotent "Capture " prefix, a
  deriveRenderPaths sibling, and CaptureResult::absolutePath. Everything else composes.
  R-F2 fallout, the ruling's only cost: the result track AND its item are tagged
  kArrangeModeId explicitly, and detectNewContent must drop added GUIDs that already carry
  a membership record — the auto-tag detector otherwise re-tags a Design-fired result to
  Design on its next tick and reverses the ruling. Firing from Design is therefore
  deliberately invisible: the render waits in Arrange. No A/B-on-the-bench behaviour exists.
  Four invariant amendments are track deliverables (the Psi precedent): shell/capture and
  shell/actions CLAUDE.md placing-path claims, ONE sentence in root CLAUDE.md naming
  the third verb without softening the prohibition, and core/view CLAUDE.md's "new tracks
  are tagged to the active mode" made conditional on carrying no membership record.
  Shared files, named: capture.{h,cpp} (destination enum + result field), main.cpp (one
  ActionTableRow), and panel_input.cpp (detectNewContent only; Psi's two named regions in
  that file are landed and are other functions) — the only pre-existing shell files
  touched. Disjoint from Gamma (core+shell/instrument) and Epsilon (core+shell/package).

Phase Lambda — ReaSampler on Linux      (none of the seventeen; a direct request 2026-08-02)
  W1  The audits                                                          [COMPLETE]
      T1 build-toolchain-audit ....... L-01..L-10, V1..V10, D1..D7
      T2 source-runtime-audit ........ L2-01..L2-12

  W2  Make it buildable, and make it honest             [4 tracks, file-disjoint]
      T1 linux-compile-blockers ...... L2-01, L2-02, L2-03      -> V1, V2
      T2 toolchain-floor ............. L-02..L-05, L-07, L-09, L-10  -> V5, V6, V8
      T3 panel-dialog-resource ....... L-01, L2-06, L2-07       -> V7   [GATED: L-F2]
      T4 locale-independent-numerics . L2-04     [the one track verifiable on Windows]
  W3  First light, and the verification sweep           [1 track, deliberately]
      T1 linux-verification-sweep .... V1..V10 + T2 section 5's seven; the deliverable
                                       is a RECORD, not a fix. V1 / V9 / the API-load
                                       count run FIRST — each reprices what follows.
  W4  Correctness and safety, repriced                  [3 tracks, disjoint by dir]
      T1 prune-deletion-safety ....... L2-05 both halves + the symlink reclaim lie
      T2 linux-font-faces ............ L2-09
      T3 source-partition-and-invariants . L-06, L2-10, L2-11 (Linux half)
  W5  Ship the extension                                [1 track; GATED: L-F4]
      T1 linux-packaging-and-install . L-08 + L-02's shipped half + both channels
  W6  The instrument module + the host-safety contract  [T1 before T2 — serial]
      T1 vst-linux-module ............ B5 (the D5 reversal, FIRST), B1, B2
      T2 vst-host-safety-contract .... D3's observable contract; passes with ZERO params
  W7  The SWELL bootstrap                               [1 track]
      T1 swell-dylib-bootstrap ....... B3 via route B3a, WITHOUT the stub's exit(2)
                                       [prereq: V9 affirmative, else L-D2 re-rules]
  W8  The X11 editor                                    [1 track]
      T1 x11-embed-view .............. B4, L2-08. The phase's one L-band source item.

  NOTHING here has been verified on a Linux machine. W2's edits are authored BLIND from the
  audits' citations and their acceptance criteria are discharged in W3 — W2 is not "done"
  until W3 runs, and this plan says so rather than pretending otherwise.
  W6 may run concurrently with W4 and W5 — file-disjoint; the only reason to serialise is
  attention. Six D-rulings are SETTLED (instrument in scope; SWELL via dlopen of REAPER's
  libSwell.so; REAPER-only editor but other hosts must degrade SAFELY; macOS out; shipped
  not developer-only; hard unlink acceptable with a platform-aware confirmation).
  OPEN, none ruled: L-F1 (CI — gates nothing), L-F2 (dialog-resource route — the ONLY fork
  gating a dispatch, W2-T3), L-F3 (copy-only invariant wording — gates nothing),
  L-F4 (declared support floor — gates W5, not a dispatch).
  Lambda registers NO VST3 parameter, ever — that table is Gamma-W4-T1's and is frozen.