docs: record Phase Ε fork rulings in the plan

Ε-F1 proprietary RSBK, Ε-F2 new bank with auto-suffix, Ε-F3 refuse on a
degraded ledger. No Ε track is gated now. Also carries Phase Ρ's plan
sections, authored concurrently in this shared checkout.
This commit is contained in:
2026-08-02 07:06:23 -04:00
parent 461351ee02
commit 61e90af547
+447 -51
View File
@@ -9,7 +9,8 @@ come from the seventeen: it came from a direct list of seven defects and refinem
(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`.
`docs/product/bank-package.md`, **and Phase Ρ**, likewise a direct request (Daniel,
2026-08-02), scoped in `docs/product/render-in-place.md`.
## What this doc is, and how it relates to the others
@@ -30,9 +31,9 @@ seventeen: it came from a direct request (Daniel, 2026-08-02) and is scoped in
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`**. So Θ-W1-T1
dispatches into `pth-w1-t1-zone-retirement`, Γ-W1-T1 into `pg-w1-t1-knob-interaction-law`,
and Ψ-W1-T1 into `ppsi-w1-t1-capture-range-exactness`.
transliterate: **Θ → `th`**, **Ξ → `xi`**, **Γ → `g`**, **Ψ → `psi`**, **Ε → `e`**, **Ρ → `r`**. So
Θ-W1-T1 dispatches into `pth-w1-t1-zone-retirement`, Γ-W1-T1 into
`pg-w1-t1-knob-interaction-law`, and Ψ-W1-T1 into `ppsi-w1-t1-capture-range-exactness`.
## Decision state
@@ -62,13 +63,30 @@ the stage lengths to 10s"* — the ceiling moves in Γ-W1-T1. **Γ-F7** (the VST
*order*) is **RULED: signal flow***"signal flow order."* **There is now NO unanswered
[Daniel]-class question anywhere in this plan.**
**That claim is scoped to Phases Θ / Ξ / Γ / Ψ, and Phase Ε reopens the class.** Phase Ε
(added 2026-08-02) carries **three** [Daniel]-class forks — **Ε-F1** (container format),
**Ε-F2** (import target), **Ε-F3** (import under a degraded ledger) — each stated with a
recommendation and its counter-argument in the phase header below and in
`docs/product/bank-package.md` §"Open forks". **Only Ε-F1 blocks a dispatch** (Ε-W1-T1);
the other two are answerable at Ε-W2 and could be ruled at implementation review if Daniel
prefers, but both are user-visible policy rather than implementation detail.
**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) reopens the class with three forks, and NONE of them gates a
dispatch.** **Ρ-F1** (multi-track: inherit the existing refusal, or loop per selected
track), **Ρ-F2** (when Design is the active mode, does the new track go to Design as
Daniel's rule states, or always to Arrange), **Ρ-F3** (tail: follow the panel setting, or
force `None`). Each is stated with a recommendation and its counter-argument in the phase
header below and in `docs/product/render-in-place.md` §"Open forks". All three are
user-visible policy rather than implementation detail, and all three are answerable at
implementation review; the phase's single track is specced against the recommended answer
in each case, so a ruling that agrees changes nothing and a ruling that disagrees changes
one named paragraph.
**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
@@ -2267,19 +2285,19 @@ actionable message, and neither direction is ever a partial landing.
`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 are OPEN and are Daniel's.** They are the first unanswered [Daniel]-class
questions in this plan since Γ closed its seven; each is stated with a recommendation and
its counter-argument in `docs/product/bank-package.md` §"Open forks".
**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 | Question | Recommendation | Blocks |
| Fork | Ruling | Specified in | Bound into |
|---|---|---|---|
| **Ε-F1** | Container format: hand-rolled `RSBK` vs. ZIP via the vendored minizip (`vendor/WDL/WDL/zlib/`) | **hand-rolled `RSBK`** — the only option where the whole codec lands pure | **Ε-W1-T1 dispatch.** One-way door: packages are in users' hands the day it ships |
| **Ε-F2** | Import target: always a new bank, or also offer merge-into-existing | **new bank by default**, merge as a *separate* bindable verb if wanted at all | Ε-W2-T2's action count and dialog; not W1 |
| **Ε-F3** | Import while the tracking ledger is degraded: allow-with-confirm, or refuse | **allow with an up-front confirm naming the consequence** — matches the accepted residual rather than inventing a new block | Ε-W2-T2's guard; not W1 |
| **Ε-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 |
**Ε-F1 is the only one that blocks a dispatch.** Ε-F2 and Ε-F3 can be carried into Ε-W2-T2
as ruled-at-review if Daniel prefers, but both are user-visible policy rather than
implementation detail, so the plan's default is to ask.
**No Ε track is gated on a decision.** Every track in this phase is dispatchable as
written.
### Phase-Ε acceptance criteria
@@ -2334,7 +2352,9 @@ new directories** (`src/core/package/`, `src/shell/package/`) that no other phas
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). **No Ε track
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.**
---
@@ -2356,10 +2376,10 @@ bytes and structs, T2 knows only paths and bytes, T3 knows only the ledger.
`src/shell/package`. Two append-only lines in one list: **textual merge adjacency, not
semantic contention.** Whichever lands second rebases.
**T1 is the wave's only gated dispatch** — it cannot start before Ε-F1 is ruled, because the
fork *is* T1's deliverable. T2 and T3 are unaffected by Ε-F1 and can start immediately: T2's
API is bytes-in/bytes-out regardless of what those bytes mean, and T3 touches no package
code at all.
**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`
@@ -2379,6 +2399,12 @@ root `CMakeLists.txt`. **Does not own:** `export_plan` / `import_plan` (Ε-W2),
`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
@@ -2425,8 +2451,8 @@ root `CMakeLists.txt`. **Does not own:** `export_plan` / `import_plan` (Ε-W2),
- `package_format_tests`, `package_manifest_tests`, `bank_package_tests` all run without
REAPER or a DAW.
**Open questions.** **[Daniel] Ε-F1** — the container format itself; this track cannot be
dispatched until it is ruled. **[propose at review]** whether `package_format` and
**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.
@@ -2613,24 +2639,39 @@ from the bank's display name (recommended, sanitized through
#### Ε-W2-T2 — `bank-import`
**Goal.** A package becomes a 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.
**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", §"Failure
modes" (import rows), §"Version tagging: both directions".
**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. **Does not own:** anything on the
export side, `bank_book`'s rules (consumed, never re-implemented), `origin_ledger`
(Ε-W1-T3's).
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.**
- **Version gate first, before any byte is written.** `minReaderVersion` above this build
- **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
@@ -2643,7 +2684,22 @@ export side, `bank_book`'s rules (consumed, never re-implemented), `origin_ledge
`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 name** — Ε-F2.
display name** — **auto-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
@@ -2667,8 +2723,19 @@ export side, `bank_book`'s rules (consumed, never re-implemented), `origin_ledge
- `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 into a project that already contains B produces a
correct result under the Ε-F2 ruling, with every id reminted and no entry lost.
- 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.
@@ -2685,11 +2752,13 @@ export side, `bank_book`'s rules (consumed, never re-implemented), `origin_ledge
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.** **[Daniel] Ε-F2** — new bank always, or merge offered; decides whether
this track ships one action or two. **[Daniel] Ε-F3** — behaviour when the ledger is degraded
at import time. **[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.
**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.
---
@@ -2751,6 +2820,294 @@ 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, and 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. 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.* 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 **render**`renderOffline`, `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 new track is tagged BEFORE the mode reapply.** An untagged track is an Arrange
member by default, so a reapply that runs first parks the brand-new capture track when
Design is active. The auto-tag poller's later observation must be idempotent, not
load-bearing.
- **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.
### Open forks — Daniel's. None gates a dispatch.
The track below is specced against the **recommended** answer in each case, so a ruling that
agrees changes nothing and a ruling that disagrees changes one named paragraph. Full
statements with counter-arguments: `docs/product/render-in-place.md` §"Open forks".
- **Ρ-F1 — multi-track.** *Recommendation: inherit the existing refusal* (one track per
fire). The stem-collapse hazard would not actually apply to a per-track loop, so the
counter is real; what it costs is bookkeeping (indices shift per insertion, partial
failure, undo label). If Daniel wants it, the honest shape is a **second wave**, not a
bigger first one.
- **Ρ-F2 — the new track's mode when Design is active.** *Recommendation: to Design, as
Daniel's rule states.* The alternative is an absolute rule (source always → Design,
capture always → Arrange). The relative rule wins on Daniel's explicit words and on the
A/B-on-the-bench use the absolute rule cannot express; it does mean firing Ρ from Design
puts nothing into the arrangement.
- **Ρ-F3 — tail.** *Recommendation: follow the panel setting.* Under Auto/Manual the placed
item is longer than the window it replaces (correct for a decaying chain) and the
exact-bounds gate is inactive (it runs only under `TailMode::None`) — inherited, not
introduced. A third option is named in the product doc: keep the panel setting but run the
gate's start-alignment check regardless of tail mode.
---
### Ρ-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, one `ActionTableRow`, two 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 new track to the active mode.
**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/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. `ProjectMedia``GetProjectPathEx(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:** `membership().tag(sourceGuid, kDesignModeId)` (covers stays *and* goes — `tag`
replaces prior single-mode membership); `membership().tag(newTrackGuid,
view.activeModeId())`; 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. **Tag order is load-bearing** (see acceptance criteria).
- **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`.
- **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.
**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.
- All three 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** (fired from Arrange; fired from Design), plus the ordering check
that the new track is never momentarily parked.
- **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.** The three [Daniel] forks above (Ρ-F1/F2/F3), none gating. **[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.
---
## Traceability — all seventeen items
The check that nothing was dropped. Every row points at a track that exists above.
@@ -2815,9 +3172,17 @@ proof it exists to give.
(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. It is also the only phase in this
plan that opens with unanswered [Daniel]-class forks rather than closing them; see
"Decision state" above.
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) are **open**, and none gates the
dispatch — the track is specced against the recommended answer in each case.
### Deliberate compressions
@@ -2934,14 +3299,15 @@ Phase Psi — The extension trust pass (none of the seventeen; a direct
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
[BLOCKED until E-F1 is ruled — Daniel]
[E-F1 RULED: proprietary RSBK. No ZIP, no zlib]
T2 package-fs-shell ............ shell/package: atomic write, streaming, pickers
[no REAPER save-picker exists; SWELL/Win32 split]
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
[OPEN: E-F2 import target, E-F3 degraded ledger]
[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
@@ -2949,8 +3315,38 @@ Phase Epsilon — The bank package (none of the seventeen; a direct reque
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). W1's three tracks are unconditionally disjoint; W2's two are
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.
NEVER touches the bank.
[OPEN, none gating: R-F1 multi-track, R-F2 the
new track's mode from Design, R-F3 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.
Three invariant amendments are track deliverables (the Psi precedent): shell/capture and
shell/actions CLAUDE.md placing-path claims, plus ONE sentence in root CLAUDE.md naming
the third verb without softening the prohibition.
Shared files, named: capture.{h,cpp} (destination enum + result field) and main.cpp (one
ActionTableRow) — the only pre-existing shell files touched. Disjoint from Gamma
(core+shell/instrument) and Epsilon (core+shell/package).
```