docs: rule Phase Rho's three forks

Refuse multi-track; result track always Arrange; follow panel tail. Rho-F2 overrides the mode-following framing: explicit Arrange tags on track and item, plus an explicit-tag-wins filter in detectNewContent.
This commit is contained in:
2026-08-02 07:40:01 -04:00
parent 734672ba0e
commit c037aa1cb6
2 changed files with 377 additions and 160 deletions
+223 -98
View File
@@ -9,12 +9,20 @@ the capture/placement separation, the prior art it borrows from, the reuse inven
that makes it small, and the handful of decisions the shape actually turns on.
Status: framed by product-designer (2026-08-02) from Daniel's direct request the same
day. **Three [Daniel]-class forks are open**Ρ-F1 (multi-track), Ρ-F2 (the new
track's mode when Design is active), Ρ-F3 (tail) — each stated with a recommendation
in §"Open forks". None of them blocks a dispatch; all three are answerable at
implementation review if Daniel prefers, but all three are user-visible policy rather
than implementation detail. Everything else below is a product-designer call with its
reasoning stated; contradict it in review with an argument, not a preference.
day; **all three [Daniel]-class forks RULED the same day**Ρ-F1 **refuse a
multi-track selection**, Ρ-F2 **the result track always goes to Arrange**, Ρ-F3
**follow the panel tail setting**. See §"Rulings" for the index and the recorded
rationale; each is specified in place in the section that owns it. Nothing in this
doc is open.
**Ρ-F2 overrode the framing, including Daniel's own original phrasing.** The request
as first stated had the new track take whatever mode was active when the action fired;
the ruling replaced that with an absolute rule. §"Mode transitions" is written against
the ruling, and the A/B-on-the-bench behaviour that mode-following would have produced
**no longer exists anywhere in this design** — do not reintroduce it as a benefit.
Everything else below is a product-designer call with its reasoning stated; contradict
it in review with an argument, not a preference.
---
@@ -23,8 +31,9 @@ reasoning stated; contradict it in review with an argument, not a preference.
**Render in place takes one selected track, renders its output over the current
range to a file, and drops that file as an item on a brand-new sibling track at the
exact position it was rendered from — then moves the source track into Design mode.**
The new track inherits the source's colour and its name with a `Capture ` prefix. The
bank is never opened, never read, never written.
The new track inherits the source's colour and its name with a `Capture ` prefix, and
belongs to Arrange unconditionally (Ρ-F2). The bank is never opened, never read, never
written.
The model Daniel named is REAPER's own *Render selected track time selection to new
track (stereo) and mute original*. Phase Ρ differs in exactly one respect, and that
@@ -235,19 +244,46 @@ different and much less predictable verb.
render *source* (`SelectedTracks`, which track scope always uses), so any selection of
more than one track refuses with `multiTrackRefusalMessage(CaptureScope::Track)`
before a single project setting is written. Ρ inherits this for free and adds no
check of its own. See fork Ρ-F1 for the alternative.
check of its own.
**Tail follows the panel setting** — None / Auto / Manual, read from
`bankPanelTailSetting()` like every other capture path. Under Auto or Manual the
rendered file is longer than the requested window by design (the chain's decay rings
past the range end), so the placed item is correspondingly longer than the source
window. That is the musically correct answer for a design chain with reverb on it,
and forcing None would be Ρ inventing a policy the rest of the tool does not have.
Two consequences to state rather than discover: the exact-bounds gate in
`OfflineRenderBackend::capture` runs **only** under `TailMode::None`, so Auto/Manual
renders are unguarded against widening (inherited, not introduced); and the placed
item's *start* is exact in every mode, because a tail is added at the end only. See
fork Ρ-F3.
**Ρ-F1, RULED by Daniel, 2026-08-02: *refuse*.** One selected track per fire is a
**settled non-goal**, in the same sense as the other entries in §"What Phase Ρ
explicitly is NOT" — not a deferral. There is no per-track loop planned, no second
wave holding one, and no seam to be left half-open on the way past. If multi-track is
ever wanted it is a new phase with its own framing, and the counter-argument that made
this a fork (the stem-collapse hazard does not apply to a per-track loop, so the
refusal is inherited rather than required) is recorded in §"Rulings" so it does not
have to be rediscovered.
**Tail follows the panel setting — Ρ-F3, RULED by Daniel, 2026-08-02: *"follow panel
tail settings."*** None / Auto / Manual, read from `bankPanelTailSetting()` like every
other capture path. Forcing `None` would be Ρ inventing a policy the rest of the tool
does not have, and a decaying design chain wants its tail when its source is about to
be silenced.
**Two consequences of that ruling, both accepted, both stated here because an
implementer meets them directly:**
1. **Under Auto or Manual the placed item is longer than the window it replaces.** The
chain's decay rings past the range end and the render carries it, so the item on
the result track extends past the source range. This is correct for reverb and
wrong for a section meant to butt against the next one — and the ruling accepts
that trade rather than splitting the behaviour. The user's lever is the panel's own
tail setting: set it to None before firing if a hard edge is wanted.
2. **The exact-bounds gate is inactive under Auto and Manual.** The gate in
`OfflineRenderBackend::capture` runs **only** under `TailMode::None`, so Ρ renders
in the other two modes are unguarded against a widened render. This is inherited
from every other capture path, not introduced by Ρ, and it is not a caveat to bury:
the phase's exactness guarantee under Auto/Manual is the *start* only.
The placed item's **start is exact in every tail mode**, because a tail is only ever
added at the end. The null test therefore holds in all three modes — the shared region
nulls; the tail simply has nothing to null against.
A third option was floated at framing and is **not ruled in**: keep the panel setting
but run the bounds gate's start-alignment check regardless of tail mode, since a tail
only ever extends the end. It is recorded as an unexercised alternative in §"Rulings"
and is not to be built into this phase.
**Mono collapse applies**, unchanged. A render whose channels are bit-identical
collapses losslessly to one channel and REAPER derives a mono item from the file
@@ -313,6 +349,14 @@ sight in the same gesture, so restoring the selection would leave the user selec
an invisible track. The new track is the workflow's next subject; select it. The edit
cursor *is* restored, since nothing about Ρ argues for moving it.
**The Ρ-F2 ruling inverts that reasoning in the Design-fired case, and the rule stays
absolute anyway.** Fired from Design, the result track is the parked one and the source
is the visible one, so leaving the result selected selects an invisible track — the
exact thing the rule exists to avoid in the other direction. Making the selection
conditional on the active mode would reintroduce mode-relative behaviour, which is
precisely what the ruling removed from this action. One rule, both cases: the result
track ends up selected, alone.
---
## The new track — index, folder, colour, name
@@ -406,11 +450,14 @@ unlike `kManagedLanePrefix` or an action-id suffix, changing it later strands no
---
## Mode transitions — resolving "stays/goes"
## Mode transitions — the source parks, the result goes to Arrange
Daniel's phrasing was *"the source track stays/goes to design mode, and the resulting
new sibling track […] stays in whatever mode was active when the action was run."*
Both halves resolve into the shipped membership model without inventing anything.
Daniel's original phrasing was *"the source track stays/goes to design mode, and the
resulting new sibling track […] stays in whatever mode was active when the action was
run."* **The second half was overridden by his own later ruling — Ρ-F2, 2026-08-02:
*"for this action which is not a capture, the result track should always go to
arrange."*** The source half stands exactly as first stated; the result half is now
absolute and mode-independent.
**Source track: unconditionally a Design member afterwards.**
`membership().tag(sourceGuid, kDesignModeId)` covers both readings in one call —
@@ -433,29 +480,91 @@ Two inherited behaviours to state rather than fight:
inherits it. Do **not** invent a cascade that tags the children — that changes the
membership model to make one feature convenient.
**New track: tagged to the mode that was active when the action fired**,
synchronously and explicitly — `membership().tag(newTrackGuid, view.activeModeId())`.
**Result track: unconditionally an Arrange member**, whatever mode was active
`membership().tag(newTrackGuid, kArrangeModeId)`, synchronously and explicitly, **not**
an `untag()` to the Arrange default. The distinction is load-bearing: an explicit
membership record is what protects the ruling from the auto-tag detector (below). An
untagged track is an Arrange member by observable behaviour but carries no record, and
a record is what the detector must be made to respect.
**This must happen before `applyMode` reapplies, and that ordering is load-bearing.**
The auto-tag poller (`panel_input::detectNewContent`, over `guid_diff::GuidBaseline`)
would tag the new track on its next tick, and would reach the same answer — but the
reapply inside Ρ's own gesture runs first. An untagged track is an Arrange member by
default, so if the active mode is Design and Ρ reapplies before tagging, the reapply
**parks the brand-new capture track** — hidden, out of the mix — and it stays parked
until the next mode switch. Tagging explicitly, first, closes that window; the
poller's later observation is then idempotent (it re-derives the same mode).
**Note what is novel here, because it is worth a reviewer's attention.** Ρ is the
**first** path in the tree to write an explicit `kArrangeModeId` record. The shipped
*tag selected tracks → Arrange* action does not — it dispatches to `doUntag()`, i.e.
Arrange-by-absence, which is why the constant appears in `view_mode_model` and
`mode_enable` but never in a `tag()` call. The record is nonetheless well-formed and
behaviourally identical to the untagged state everywhere that matters:
`ViewModeModel::isMember` answers `true` for `arrange` and `false` for `design` in
both cases, the derived-parent and lane rules read the same, and `untag()` still
returns it to absence. The only differences are one more entry in the persisted
membership index and — the point — its visibility to the detector's filter. **[verify
— DAW]** that a project saved with an explicit Arrange record round-trips and shows no
behavioural difference from an untagged track; the JSON round-trip is unit-testable and
should be tested, but the live view behaviour is not.
**The placed item is tagged Arrange too**, explicitly, for the same reason. Item
membership is a separate index from track membership and it is what drives lane
minting; leaving the item untagged hands it to the detector, which tags a new item to
the active mode whenever its track has no pre-existing single-mode content — and a
brand-new track never does. After `InsertMedia`, enumerate the new track's items (it
is brand new, so they are exactly the ones just placed) and tag each;
`shell/capture/item_read::itemGuid` is the existing GUID seam.
### The one hazard the ruling creates — the auto-tag detector
The ruling **dissolves** the ordering hazard the mode-following rule carried and
**creates a different one** in its place. Both are stated, because the first was
written into the plan's acceptance criteria and is now wrong.
**Dissolved.** Under mode-following, the tag had to precede the `applyMode` reapply:
an untagged track is an Arrange member by default, so a Design reapply running first
would have parked a track destined for Design, and it would have stayed parked until
the next switch. Under the ruling the result track *is* an Arrange member, so a Design
reapply parking it is the correct outcome and the ordering no longer changes what the
user sees. Tag first anyway, for state hygiene — it is simply no longer load-bearing.
**Created, and it is a defect rather than a nuance.** The panel's new-content detector
(`panel_input::detectNewContent` over `guid_diff::GuidBaseline`, feeding
`view_mode_model::autoTagNewContent`) tags **every new track to the active mode**,
unconditionally: it diffs live GUIDs against the previous tick and never consults the
membership index. Fire Ρ while Design is active and, on the next timer tick, the
detector re-tags the brand-new result track — and its item — from Arrange to Design,
silently reversing the ruling inside a second. Nothing in Ρ's own gesture can outrun
it, because it runs after.
**The fix is one rule, and it is worth stating generally: an explicit tag wins over
the detector.** `detectNewContent` drops any added GUID that already carries a
membership record (`MembershipIndex::query(guid) != nullptr`) before building its
auto-tag input. Two lines, at the point in that function where the model is already in
hand. The rule is right beyond Ρ — the detector exists to classify content the *user*
made, not content the tool made and has already classified — and it is a strict
improvement on an existing rough edge: a track brought back by undo keeps its original
mode instead of being re-tagged to whatever mode happens to be active at the time.
Two alternatives were considered and rejected. Re-arming the baseline after Ρ's
gesture (the `bankPanelNotifyProjectLoaded` mechanism) works, but it absorbs an entire
tick of genuinely new content silently — a blunt instrument aimed at two GUIDs.
Changing `autoTagNewContent`'s own track rule changes Design View's behaviour for
every caller in order to serve one.
The resulting behaviour, stated completely:
| Active mode when fired | Source afterwards | New track afterwards | What the user sees |
| Active mode when fired | Source afterwards | Result track afterwards | What the user sees |
|---|---|---|---|
| **Arrange** | Design — parked, hidden, FX offline | Arrange — visible, in the mix | The headline case. The design chain vanishes from the arrangement and its audio takes its place, at the same position, same colour, named after it. |
| **Design** | Design — visible | Design — visible | Both on the bench, side by side, for A/B. Neither reaches Arrange. |
| **Design** | Design — visible on the bench | Arrange — parked while Design is active | The bench keeps only the source. The render is not on screen; it is waiting in the arrangement, and switching to Arrange shows it in the source's place. |
The Design-active case is coherent — you are iterating on the bench and want the
render beside its source — but it means firing Ρ from Design never puts anything into
the arrangement. That follows directly from Daniel's stated rule and is a genuinely
useful second behaviour, not a defect. It is also the subject of fork Ρ-F2.
**The Design-fired case produces no visible change, and that is the ruling's accepted
cost.** Fire Ρ from the bench and the immediate feedback is that nothing appears. The
price buys the action meaning exactly one thing wherever it is fired — *commit this
design work into the arrangement* — which is the reasoning behind the ruling: Ρ is not
a capture, and a capture's mode-relative habits do not apply to it.
**Feedback deserves reconsideration for this case, and only this case.** Ρ is
otherwise silent on success because the new track is the feedback; fired from Design
there is no visible new track, so a silent success is indistinguishable from a no-op.
**[propose at review]** whether the Design-fired path should emit a one-line
`ShowConsoleMsg` naming the track it created. Recommendation: yes — it costs one line
and one string, and it is the only place in the phase where success is invisible.
**Lane minting runs**, via `mintManagedLanes(view, nullptr)` before the reapply, on
the same path `doMoveItems` already uses — so a track that ends up carrying content
@@ -535,8 +644,12 @@ are named:
- **Not a bank capture, in any form.** No index entry, no ledger record, no
generation bump, no instance reload.
- **Not multi-track** (this phase — see Ρ-F1). One selected track per fire; more than
one refuses.
- **Not multi-track.** Ρ-F1, RULED: one selected track per fire; more than one refuses
with the message that already exists. Settled, not deferred — no per-track loop is
planned, half-built toward, or left a seam for.
- **Not mode-following.** Ρ-F2, RULED: the result track is an Arrange member
unconditionally. There is no variant that follows the active mode, no per-fire
choice, and no bench-visible result — firing from Design puts nothing on the bench.
- **Not item-scoped.** No `RENDER_ITEMS_IN_PLACE`, no item-extent range fallback. The
seam is left open by the id family; the feature is not built.
- **Not a tempo-conforming insert.** No conform variant, ever — a conform would
@@ -549,13 +662,14 @@ are named:
- **Not a new persisted state.** Membership writes go into the existing `"reasampler"`
view section. Ρ adds no key, no version rung, no wire format.
- **Not a new directory.** Three small pure additions to existing `core/capture`
modules, one new shell TU in `shell/capture`, one row in `main.cpp`.
modules, one new shell TU in `shell/capture`, two lines in `panel_input.cpp`, one
row in `main.cpp`.
---
## Invariant amendments this phase owns
Two statements in the tree become false the moment Ρ lands, and amending them is a
Three statements in the tree become false the moment Ρ lands, and amending them is a
**deliverable of the track**, not a follow-up — the precedent is Phase Ψ, where three
such amendments were carried as acceptance criteria of the tracks that broke them. A
track that lands Ρ without these reads as an invariant breach in review.
@@ -572,6 +686,15 @@ track that lands Ρ without these reads as an invariant breach in review.
lives in `shell/capture/`, but the sentence reads as a claim about the system.
Amend it to be explicit that it scopes to *this directory*, and cross-reference the
third verb.
3. **`src/core/view/CLAUDE.md` §Invariants** — *"New tracks are tagged to the active
mode at creation."* Ρ-F2 makes this conditional for the first time: the detector's
active-mode rule now applies only to a GUID that carries **no** membership record,
because an explicit tag wins over it (§"Mode transitions"). Amend the sentence to
say so, 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/`'s
own CLAUDE.md describes `panel_input` as "the new-content auto-tag timer" without
restating the rule, so it needs no amendment; if that changes, the rule has one
home and this is it.
Root `CLAUDE.md` §"The load-bearing principle" should gain **one sentence**, not a
rewrite: that a render which never enters the bank and never leaves it is a third
@@ -605,6 +728,11 @@ directory:
derivation) and `CaptureResult::absolutePath`. **No behavioural change on the bank
path**: the enum defaults to `Bank`, and the bank branch must be byte-identical to
today.
- `shell/panel/panel_input.cpp` — two lines inside `detectNewContent`: drop added
GUIDs that already carry a membership record, so an explicit tag wins over the
auto-tag detector (§"Mode transitions"). This edit exists **only because of the Ρ-F2
ruling**; without it the ruling reverses itself on the next timer tick. No other
function in the file is touched.
- `src/app/main.cpp` — one `ActionTableRow`.
**Performance posture:** every surface is cold — one gesture, once. None of the named
@@ -633,9 +761,15 @@ than a discovery. Nothing in Ρ is unit-testable past the pure functions.
mono, and confirm it sums at the same level as 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 Ρ.
- **Both mode transitions** — fired from Arrange (source parks, new track visible) and
fired from Design (both visible, neither in Arrange) — plus the ordering check that
the new track is never momentarily parked.
- **Both mode transitions** — 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 switching 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 the check that catches a missing
explicit-tag-wins filter or an untagged item — either of which silently reverses the
Ρ-F2 ruling. The old ordering check (that the new track is never momentarily parked)
no longer applies: under the ruling the result track is an Arrange member and a
Design reapply parking it is correct.
- **Undo** — one Ctrl-Z removes the track and item and restores the folder depth; the
file survives; the source stays tagged Design.
- **The name and colour clone**, including a second run over an already-prefixed track
@@ -645,60 +779,51 @@ than a discovery. Nothing in Ρ is unit-testable past the pure functions.
---
## Open forks — Daniel's
## Rulings — Daniel's, 2026-08-02
### Ρ-F1 — Multi-track: inherit the refusal, or render each selected track?
All three [Daniel]-class forks this doc opened were ruled the same day it was framed.
Nothing here is open. This section is an index; each ruling is **specified** in the
section that owns it, and that section is the implementation-binding text.
**Recommendation: inherit the refusal.** One selected track per fire; more than one
refuses with the message that already exists. Daniel's phrasing was singular ("the
source track"), and it costs zero code.
| Fork | Ruling | Specified in |
|---|---|---|
| **Ρ-F1** | **Refuse** a multi-track selection. One track per fire, inherited from `isMultiTrackStemRender`. A per-track loop is a settled non-goal for this phase, not a deferral | §"The render" — the multi-track paragraph; §"What Phase Ρ explicitly is NOT" |
| **Ρ-F2** | **The result track always goes to Arrange**, whatever mode was active. *"For this action which is not a capture, the result track should always go to arrange."* | §"Mode transitions" — the absolute rule, the item tag, and the auto-tag-detector fix it requires |
| **Ρ-F3** | **Follow the panel tail setting** (None / Auto / Manual), with both consequences accepted | §"The render" — the tail paragraph and its two numbered consequences |
**The counter-argument is real**, which is why this is a fork rather than a call.
REAPER's model action operates on the selection, and the multi-track *stem-collapse*
hazard does not actually apply here: Ρ could loop, running `renderOffline` once per
selected track with a one-track source each time, and every individual render would
be single-track and safe. What it would cost is not the render but the bookkeeping —
each insertion shifts the indices of every later track, so the folder arithmetic must
be re-derived per iteration; and the undo label, the partial-failure story ("three of
five rendered"), and the selection-afterwards rule all have to be answered.
**Ρ-F2 went against the framing, and against the request's own original wording.**
The framing recommended mode-following on the strength of Daniel's first phrasing
("stays in whatever mode was active") and of one use it enabled — an A/B of a chain
against its own render, both on the bench, neither touching the arrangement. The
ruling took the alternative the framing had itself named: an absolute rule, because Ρ
is *not a capture* and the mode-relative habit belongs to the capture pillar, not
here. **That A/B use no longer exists, and no text in this doc or in `PLAN.md` may
still claim it as a benefit.** The cost the ruling accepts is that firing Ρ from
Design produces no visible change; the compensating console message is a
[propose at review] item in §"Mode transitions".
If Daniel wants multi-track, the honest shape is a **second wave**, not a bigger
first one — the single-track verb is a strict prerequisite either way, and the design
already leaves room (the placement function takes an index and returns a plan, so a
loop just re-reads the depth list each pass).
**The ruling also has a cost the framing did not anticipate**, recorded here so it is
not read as scope creep at review: the panel's auto-tag detector tags every new track
to the active mode, so without an explicit-tag-wins filter the Design-fired case
reverses the ruling on the next timer tick. The two-line fix in
`panel_input::detectNewContent` is a **deliverable of this phase**, caused by this
ruling. Under mode-following it would not have been needed — the detector would have
agreed with Ρ.
### Ρ-F2 — When Design is the active mode, where does the new track go?
**Ρ-F1 and Ρ-F3 both matched the recommendation**, so nothing in the spec moved. The
counter-arguments are kept because they are the reason each was a fork rather than a
call, and they will resurface: for Ρ-F1, that the stem-collapse hazard does **not**
apply to a per-track loop (each iteration renders one track), so the refusal is
inherited rather than forced — what a loop would actually cost is bookkeeping (indices
shift per insertion, so the folder arithmetic re-derives each pass; plus an undo label,
a partial-failure story, and a selection-afterwards rule). For Ρ-F3, that a drop-in
replacement arguably wants exact length, and that forcing `None` would have kept the
exact-bounds gate live on every Ρ render.
**Recommendation: to Design, as Daniel stated** — the new track takes whatever mode
was active. Implemented as written.
**The alternative is defensible**: make Ρ *absolute* rather than mode-relative — the
source always goes to Design, the capture always goes to Arrange, regardless of which
stance you fired from. That reading makes Ρ mean "promote this design work into the
arrangement" in every context, which is arguably the verb's actual purpose, and it
means the action does the same thing wherever you are.
The relative rule wins on Daniel's explicit words and on one real use: firing Ρ from
Design to get an A/B of a chain against its own render, on the bench, without either
touching the arrangement. That is a genuinely useful thing the absolute rule cannot
express. But it does mean a user in Design mode who expects to have "committed
something to the arrangement" has not. **One confirm.**
### Ρ-F3 — Tail: follow the panel setting, or force None?
**Recommendation: follow the panel setting** (None / Auto / Manual), for consistency
with every other render path and because a decaying design chain wants its tail when
its source is about to be silenced.
**The counter:** Ρ's pitch is a drop-in replacement for the source, and under Auto or
Manual the placed item is longer than the window it replaces — which is correct for
reverb and wrong for a section you intend to butt against the next one. Forcing None
would also keep the exact-bounds gate active on every Ρ render, since that gate runs
only under `TailMode::None`.
A third option exists and is worth naming: follow the panel setting but **run the
bounds gate's start-alignment check regardless of tail mode**, since a tail only ever
extends the end. That gets both properties at the cost of a small change to the gate's
condition, and it is the option I would take if Daniel wants the guard without losing
the tail. Not recommended by default only because it edits a shared gate for one
caller's benefit.
**One unexercised alternative, recorded and not built.** For Ρ-F3, a third option was
floated at framing: follow the panel setting *and* run the bounds gate's
start-alignment check regardless of tail mode, since a tail only ever extends the end.
It would recover the widening guard on the start edge without losing the tail, at the
cost of editing a gate shared by every capture path for one caller's benefit. It is
**not ruled in** and is not part of this phase. If the unguarded Auto/Manual render
ever produces a real bug, this is the shape of the fix.