Merge dev into phase-b-multibank (integrate parallel M7/8 + Phase D work before dev promotion)

# Conflicts:
#	CLAUDE.md
#	CMakeLists.txt
#	src/actions.cpp
#	src/bank_panel.cpp
#	src/persist.h
This commit is contained in:
2026-07-25 23:30:44 -04:00
42 changed files with 5289 additions and 140 deletions
+252
View File
@@ -0,0 +1,252 @@
# Provenance — product notes
Framing, rationale, and the dual-canvas reconciliation behind the reshaped
**Milestone 10 (provenance)**. The tickable spec lives in `PLAN.md` (M10); the
authoritative technical detail is `CONTEXT.md` (§Data model, §capture) plus this
note for the reconciliation calls. This doc holds the *why* and the open forks so
they don't clutter the build docs.
Status: **SETTLED (2026-07-23).** Reshaped from the old "provenance + null-test
verify" M10. Two decisions were fixed by Daniel up front (see *What was cut* below).
The four dual-canvas interaction forks are now resolved: **P1=a thin fingerprint,
P2=a bank-only re-capture**, which makes **P3 and P4 moot (closed)**. The fork
analysis below is retained as the rationale record — each fork is stamped with its
resolution inline; nothing here is open.
---
## What was cut (fixed by Daniel — do not reopen)
- **The null-test verification ACTION is cut.** Daniel verifies bit-accuracy
himself when he cares (he already null-tested the first capture spike, M3). The
tool ships no null-test button.
- **The "true pre-FX dry capture" mechanism is dropped.** The old M10 note said the
null test would require a true pre-FX dry render (bypass-around-render or the M8
realtime pre-FX tap). With the null-test action gone, that mechanism has no
consumer and is dropped too. `CaptureRequest.wetDry` stays in the struct as an
inert seam (M7 already retained it), but M10 does **not** build a dry path.
What survives from the old M10: **provenance + "re-capture from source,"** which
Daniel confirmed is genuinely useful — with the added constraint that it must be
coherent with the dual-canvas (Design View / two-canvas) architecture built in
parallel.
---
## What provenance is (and what already exists)
**Provenance records where a sample came from, so a sample can be regenerated from
its source.** The concrete case: you capture something into the bank, place it,
process it further, and re-capture the processed result — provenance is the thread
back from the child sample to the parent it was resampled from, plus enough about
the capture to reproduce it.
**Most of the data model already exists.** `Sample` (M1, landed) already carries:
```
std::optional<Provenance> provenance; // set only when resampled
struct Provenance {
std::string parentSampleId;
std::string fxChainSnapshot;
};
```
and it already JSON-round-trips (M1's lossless-round-trip test covers it). So M10
is **not** "add provenance fields" — the seam is cut. M10 is:
1. **Populate** `provenance` on captures that resample from an existing bank sample.
2. **Consume** it via a "re-capture from source" action that regenerates the sample.
3. **Reconcile** both with the dual-canvas model (the new work — see below).
### What `fxChainSnapshot` should mean now (given the M7 rework)
The old note assumed provenance would snapshot a chain for a *pre-FX dry* render.
That's gone. Under the **shipped M7 capture model**, capture is always wet and the
control is the **FX scope** (item = item/take FX only; track = item FX + that
track's own track FX), with the out-of-scope chain neutralized to unity by
`FxBypassGuard` for the render. There is no wet/dry dial.
So `fxChainSnapshot` should record **the capture recipe, not a dry-render chain**:
the scope, the source range (already on `Sample.sourceRange`), the source track
GUID(s) (already on `Sample.trackGuids`), the tail setting, and — the genuinely new
bit — enough of the **source FX-chain identity at capture time** that "re-capture
from source" can tell whether the source still matches what was captured. This is a
*fingerprint for reproducibility*, not a mechanism for a different render mode.
> **Fork P1 — how much chain state does `fxChainSnapshot` carry? — CHOSEN: (a)
> thin fingerprint (Daniel, 2026-07-23).** Two shapes:
> **(a) thin fingerprint** — a hash/summary of the source scope + FX-chain identity
> at capture time, used only to detect drift ("source has changed since capture")
> and to re-run the *same* capture request; or **(b) fat snapshot** — a full
> serialized FX-chain state string (`TrackFX` chunk) that re-capture could restore
> before rendering, so the regenerated sample matches even if the user has since
> tweaked the chain. (a) is simpler, non-destructive, and matches "re-capture
> reflects the source as it is *now*"; (b) is heavier, mutates the live chain during
> re-capture (a new destructive-ish surface), and re-opens some of the pre-FX-dry
> complexity we just cut. **Lean: (a) thin fingerprint.** Re-capture-from-source
> most naturally means "run the capture again against the source's *current* state"
> — that's the useful workflow (I changed the source, give me the updated sample).
> The fingerprint's job is to *tell* the user the source drifted, not to freeze it.
> **Resolved 2026-07-23: (a). `fxChainSnapshot` is a capture-recipe fingerprint for
> drift detection and re-run — not a serialized chain to restore.**
---
## The dual-canvas reconciliation (the real new work)
Daniel's constraint, verbatim: *"with the sound design canvas parallel, I think
provenance is genuinely useful, but we should make sure it's compliant with the
dual canvas stuff."*
The dual-canvas ("two-canvas," Phase D2) architecture that landed in parallel:
Design View is a **mode projection** over one timeline reaching both **tracks**
(D1 parking) and **items** (D2 fixed lanes). New content is **auto-tagged to the
active mode** at creation. Membership + lane-ownership are GUID-keyed and persist
in the `"reasampler"` `view_state` section — a **separate** pure module
(`view_mode_model`) from the bank (`bank_model`). The settled placement rule
(CONTEXT §Capture placement — mode-aware): *an explicit placement while in Design
mode lands the item in the Design lane.*
There are exactly **four** genuine interaction points between provenance and this
model. Each is a fork for Daniel.
### Where re-capture lands (the load-bearing one)
"Re-capture from source" regenerates a sample into the bank. Per the load-bearing
capture principle, **regenerating the bank sample never inserts into the timeline**
— so at the bank level there is *no* canvas interaction: the regenerated file + index
entry land in the bank exactly as any capture does, and the bank is mode-agnostic.
The interaction only appears **if re-capture also re-places** the regenerated sample
onto the timeline (replacing the old placed item). That is a *placement*, and
placement is mode-aware under D2.
> **Fork P2 — does "re-capture from source" re-place, or only refresh the bank
> entry? — CHOSEN: (a) bank-only re-capture (Daniel, 2026-07-23).** Two shapes:
> **(a) bank-only re-capture** — regenerate the file + update the bank Sample
> in place; the user re-places manually if they want the new version on the
> timeline. Fully honors the load-bearing principle with zero canvas coupling;
> simplest; matches how every other capture behaves (capture ≠ placement).
> **(b) re-capture-and-replace** — regenerate *and* swap the placed timeline item
> for the new file. Convenient, but it is an auto-placement path, so it must obey
> the D2 mode-aware placement rule (lands in the active mode's lane / the original
> item's lane) and it touches the timeline (undo block, non-destructive to
> everything else). **Lean: (a) bank-only.** It keeps M10 inside the capture
> pillar's clean "capture never places" line and defers all the canvas-placement
> complexity. (b) can be a later opt-in ("re-capture and replace in place") once (a)
> proves the provenance thread. If Daniel wants (b), P3 and P4 below become live.
> **Resolved 2026-07-23: (a). Re-capture regenerates the file into the bank and
> updates the Sample in place; it never places/replaces on the timeline. P3 and P4
> are therefore moot — closed (b) can still be revisited as a later opt-in.**
### Does provenance need to record canvas/mode membership?
`Sample` (bank) and `Membership`/`LaneOwnership` (view model) are **separate pure
modules today, by design** — the bank is mode-agnostic (a sample is just a file +
metadata; it doesn't know it was placed in Design). The question is whether
provenance must break that separation to record *which canvas/lane* the source item
lived in, so re-capture can put the regenerated sample back there.
Under Fork P2 = (a) bank-only, the answer is **no** — re-capture doesn't place, so
it needs no canvas memory; the bank stays mode-agnostic and the two pure modules
stay decoupled. Under P2 = (b) re-place, the answer becomes **yes, partially**.
> **Fork P3 — (only live if P2 = re-place) does provenance store canvas/lane
> membership? — CLOSED/moot under P2=a (2026-07-23).** If re-capture re-places, where
> does it land?
> **(a) active-mode rule** — re-placement follows the *same* D2 auto-tag/placement
> rule as any explicit placement: it lands in whatever mode is active *now*.
> Provenance stores **nothing** about canvas; the view model's existing rule
> governs. Keeps `bank_model` mode-agnostic.
> **(b) origin-lane memory** — provenance records the source item's mode/lane at
> capture time (a GUID + mode-id or lane-key) so re-capture lands the regenerated
> sample back in the *original* canvas regardless of the active mode. More faithful
> to "put it back where it was," but it couples `bank_model` provenance to
> `view_mode_model` identifiers — a cross-module reach the architecture currently
> avoids. **Lean: (a) active-mode rule**, kept in the view model; provenance stays
> pure bank metadata with no view-model ids. Only reach for (b) if "re-capture
> restores the exact original lane" is a stated requirement.
> **Closed 2026-07-23: moot under P2=a. Re-capture never places, so it stores no
> canvas memory; provenance stays pure bank metadata and the two modules stay
> decoupled. Revisit only if re-capture-and-replace (P2=b) is later adopted.**
### Re-capture and auto-tagging
D2 auto-tags **new** track/item GUIDs (detected by the panel-timer GUID diff)
to the active mode. A re-placed item (P2 = b) is a *new* item GUID on the timeline,
so it would be auto-tagged to the active mode automatically — which is exactly Fork
P3 = (a) behavior, for free, via the existing detection path. No special-casing
needed *unless* Daniel wants origin-lane memory (P3 = b), in which case re-capture
must tag the new item explicitly and **suppress** the auto-tag for that GUID (or the
two fight).
> **Fork P4 — (only live if P2 = re-place AND P3 = origin-lane) does re-capture
> preserve or re-run auto-tagging? — CLOSED/moot under P2=a (2026-07-23).** If
> provenance restores the origin lane, the
> re-placed item must be tagged to the *origin* mode, not the active mode — so
> re-capture has to write the membership itself and exempt that GUID from the
> timer's auto-tag (same class as the manual-lane exemption already in
> `autoTagNewContent`). **This fork only exists under P2=(b) + P3=(b).** Under the
> leaned defaults (P2=a, or P2=b + P3=a) it does not arise: bank-only re-capture
> places nothing, and active-mode re-placement rides the existing auto-tag path
> unchanged. **Closed 2026-07-23: moot under P2=a — bank-only re-capture places
> nothing, so no auto-tag interaction arises.**
### Summary of the settled path
Daniel took the leans (**P1=a thin fingerprint, P2=a bank-only re-capture**,
2026-07-23), so the reconciliation collapses to almost nothing: provenance is
**pure per-sample bank metadata**, `bank_model` and `view_mode_model` **stay
decoupled**, and M10 touches **no** canvas code. The dual-canvas compliance is
satisfied by *staying on the right side of the load-bearing line* (capture/re-capture
never places), not by new coupling. P3 and P4 are moot (closed) — they were only
live if re-capture also re-placed onto the timeline.
The settled shape: **keep provenance in the bank, keep re-capture a bank-only
regenerate, and let the existing D2 placement rule handle the timeline if and when
the user manually re-places.** It is the smallest thing that delivers the useful
workflow and the cleanest against the architecture.
---
## Persistence / precision-invariant implications (spec-level)
- **Provenance is already-persisted metadata.** `Sample.provenance` already
serializes in the `BankIndex` JSON (M1) under the existing `bank_index` /
(post-Phase-B) `banks` ext-state key. **Populating it adds no new persistence
surface** — the round-trip test already exercises the field. The only spec note:
if Fork P1 grows `fxChainSnapshot` from a thin string to a fat FX-chunk (P1=b),
the field is still one string on `Sample`, so the JSON shape is unchanged, but the
blob gets heavier — a size consideration, not a schema one. Under P1=a (thin
fingerprint) the field stays small.
- **Under the leaned path, provenance touches `view_state` not at all.** No
lane-ownership or membership data is added for provenance; the view section is
unchanged. (Only P3=b would add view-model ids into provenance — and that would be
the argument *against* P3=b.)
- **Precision invariants are unaffected.** Re-capture is a capture: it produces a
file deterministically (bit-identical repeats hold — a re-capture with an
unchanged source and request is byte-identical to the original capture, which is
itself a nice provenance property), it is non-destructive to source items/tracks
(`FxBypassGuard` snapshot/restore, as M7), it honors exact bounds, and it writes
only relative paths. **Do not design the serialization here** — this is spec-level;
the implementer owns the JSON encoding of whatever P1 shape Daniel picks.
- **Do not reintroduce the dry path.** The precision-invariant list in CONTEXT still
names the null test as the "trust anchor." With the action cut, that line is now
historical framing, not an M10 deliverable — flag for doc-keeper to reconcile when
M10 lands, but M10 does **not** ship a null-test action or a pre-FX dry render.
---
## Decision list (settled 2026-07-23)
1. **P1 — `fxChainSnapshot` shape: CHOSEN (a) thin reproducibility fingerprint.**
(Rejected: (b) fat serialized FX-chain chunk.)
2. **P2 — re-capture scope: CHOSEN (a) bank-only regenerate.** (Rejected for now:
(b) re-capture-and-replace-on-timeline; may return as a later opt-in.)
3. **P3 — canvas memory: CLOSED/moot under P2=a.** Was only live under P2=b; lean
was (a) active-mode rule, provenance stores no view ids.
4. **P4 — auto-tag interaction: CLOSED/moot under P2=a.** Was only live under P2=b +
P3=b.
Picks 1a + 2a make P3 and P4 moot and keep M10 a small, decoupled, capture-pillar
milestone. The PLAN M10 points are locked to this path.
+364
View File
@@ -0,0 +1,364 @@
# Removal & prune — product notes
Framing, rationale, and open forks behind the two missing removal capabilities:
**sample-remove** (a sample-level index verb) and **prune** (the file-lifecycle
path CONTEXT.md keeps forward-referencing but never scoped). The tickable spec
lives in `PLAN.md` (Phase B point B5 for remove; **Phase R** for prune) and the
authoritative technical detail in `CONTEXT.md` (§Sample removal, §Prune — file
lifecycle). This doc holds the *why* — the workflow, the guardrails, the
index-vs-file boundary, and the forks that need a Daniel decision.
Status: framed by product-designer (2026-07-23); **all five forks settled by Daniel
(2026-07-24)** — R-A this-bank-primary, R-B batched REAPER undo points
(Phase-B-wide), R-C trash-preferred-with-unlink-fallback, R-D owned-file manifest
(seam lands early in Phase B / capture), R-E manual action + panel button. The
decisions are folded into the fork sections below and into the B5 / Phase R spec
prose in CONTEXT.md and the tickable points in PLAN.md.
---
## The one boundary that governs everything: index vs. file
ReaSampler already draws a hard line, stated repeatedly in CONTEXT.md: **a bank
operation touches the *index*, never the *file*.** Move, copy, evacuate, and
delete-bank are all index-only; files persist on disk "until prune." Every
removal capability below sits on exactly one side of that line, and keeping the
two verbs on opposite sides is the whole design.
- **Sample-remove drops an index entry.** It is the sample-level sibling of the
bank verbs — move/copy/evacuate all keep the sample *somewhere*; remove is
"drop this entry outright." It is **index-only, non-destructive to the file**,
and — exactly like a plain delete-bank of a non-empty bank — it can *create*
an orphan when it removes the last index reference to a file. It sits on the
**same side of the line as every existing Phase B op.**
- **Prune deletes files off disk.** It is the *only* operation in the entire
system that removes bytes. It reconciles the physical bank folder against the
union of all bank indices and reclaims files referenced by no bank. It sits on
the **file side of the line, alone.**
So the crisp statement, worth putting in the spec verbatim:
> **Remove creates orphans; prune reclaims them.** Sample-remove and delete-bank
> drop index entries and may leave a file referenced by nothing. Prune is the
> single path that turns such an orphan back into free disk space. No other
> operation deletes a file; prune deletes *only* files no index references.
This is why they are two different scope objects (Phase B vs. Phase R), even
though a naive reading ("both are 'delete' verbs") would lump them together.
---
## Sample-remove — the missing sample-level verb
### What the user is doing
The user has a sample they no longer want in a bank (or in the pool): a bad take,
a duplicate they don't want collapsed, a sample they filed into "Drums" by
mistake. Today they can *relocate* it (move/copy/evacuate) but they cannot drop
it. Remove is the "get this out of here" verb. Two intents hide inside it, and
the distinction is a fork (R-A below):
1. **Remove from *this* bank** — drop the entry from the bank the user is looking
at, leaving any copies in other banks untouched. (If the sample was copied
into "Drums" and also lives in the pool, remove-from-Drums leaves the pool copy
alone.)
2. **Remove from *everywhere*** — drop every index entry for this sample across
all banks in one act ("purge this sample from the library").
### Reconciling with existing invariants
- **Collapse-by-hash:** unaffected. Remove operates on a specific `Sample` entry
in a specific `BankIndex` (the `remove` primitive `bank_model` already has, per
CLAUDE.md — B5 exposes it, it does not add it). Because dedup is per-bank and
cross-bank dedup is deliberately *not* enforced, "remove from this bank" and
"the same hash still lives in another bank" coexist cleanly — that is the same
coexistence copy already relies on.
- **Files-are-never-deleted-by-a-bank-op:** upheld. Remove is index-only, exactly
like move/copy/evacuate/delete-bank. When remove drops the *last* reference to a
file, it produces the **same orphaned-until-prune state** a non-empty
delete-bank already produces — a designed state, not a new hazard class. The
file is reclaimed later by prune, never by remove.
- **Pool privileges:** the pool cannot be *deleted, renamed, or evacuated*, but
individual samples **can** be removed from the pool — otherwise the pool would
become a roach-motel (samples check in, never leave except by moving to a named
bank). Remove-from-pool is allowed; it is the pool's own "drop this sample"
verb. (The pool-as-container privilege is untouched; only its *contents* are
removable.)
- **Non-destructive:** remove mutates only index + ext-state, touches no file and
no timeline item — the Phase B non-destructive guarantee extends to it verbatim.
### Guardrails
Remove is *less* dangerous than it first looks, because it never deletes a file —
the bytes survive on disk until an explicit prune. So the recovery story is: an
accidental remove loses the *index entry*, not the audio. But there are two
sharpnesses to guard:
- **Last-reference remove is the orphan-maker.** Removing a sample that exists in
only one bank orphans its file (until prune). This is the same footgun as
non-empty delete-bank, and it deserves the same treatment: **confirm when the
remove drops the last index reference** ("Remove 'kick_03'? It is in no other
bank — its file will remain on disk until pruned."). A remove of a sample that
still lives in another bank is cheap and reversible-in-spirit (re-copy it back)
and need not confirm. This makes the confirmation *earned* by actual risk rather
than fired on every remove.
- **Undo.** REAPER's own undo stack does not natively cover ext-state index
mutations, so this is a Phase-B-wide decision (fork R-B, **settled**): bank/index
mutations integrate into REAPER's undo system as **batched undo points**
(`Undo_BeginBlock` / `Undo_EndBlock`), so one bank operation is one Ctrl-Z. Remove
is where the gap first bites — it is the first verb whose *only* effect is
destruction of an index entry with no relocation — but the fix is shared by every
Phase B verb.
### Where it lives
Remove is a `bank_book`/`BankIndex` verb (pure), a bindable `actions` entry, and a
`bank_panel` affordance on the current selection — the exact three-layer shape
every Phase B verb already takes. That is why it belongs **in Phase B as B5**, not
in a phase of its own: same modules, same pattern, same side of the index/file
line. It is the verb Phase B forgot, not a new pillar.
---
## Prune — the file-lifecycle path CONTEXT.md kept promising
### What the user is doing
The user has been working for a while: capturing, re-capturing (M10), deleting
banks, removing samples. Each of those left files on disk that no index references
any more — the "orphaned-until-prune" state the multi-bank spec designs in on
purpose. Over a long project the bank folder accumulates dead `.wav` files that
cost disk and clutter. **Prune is the reclaim pass**: "sweep the bank folder,
delete the files nothing references, tell me what you reclaimed."
This is the path CONTEXT.md forward-references in at least four places ("files
persist on disk until prune," "the capture/prune path reclaims it") but never
scopes. It is a real, promised capability with **no phase, no module, no point**
— a dangling reference the plan has to make good on.
### The load-bearing precedent: prune-on-reconcile already exists for modes
ReaSampler already shipped this exact shape once. Design View's `view_mode_model`
has **`ViewModeModel::reconcile(liveGuids)`** — a pure function fed the live set
(the tracks that still exist), returning the residual membership entries to drop
(CONTEXT.md §Design View: "prunes orphaned snapshots on every toggle/load;
tolerates unknown/stale GUIDs (prune on reconcile)"). Prune is the **file-pool
mirror of that pure pattern**:
> `reconcile(liveGuids)` reconciles *membership entries* against *live tracks*.
> Prune reconciles *files on disk* against *referenced files* (the union of every
> bank's index). Same shape — feed the pure core the live set, get back the
> residuals — one level down (files instead of GUIDs).
This is the "mirror the existing pure pattern" move the whole codebase is built
on (`bank_model`, `view_mode_model`, `bank_book` are all the same pure-registry
shape). The **pure part of prune** — "given the set of files on disk and the set
of files referenced by the book, compute the orphan set" — is a REAPER-free,
unit-testable function that belongs with the pure cores. Only the two ends are
shell work: *enumerating* the bank folder (filesystem I/O) and *deleting* the
orphans (filesystem I/O). Keep the decision (which files are orphans) pure and
tested; keep the I/O thin. This is the same pure/shell split as everything else.
### Reconciling with existing invariants
- **This is the one place the "files are never deleted" rule is *intentionally*
broken — and it must be the *only* one.** Every other invariant says "no bank op
deletes a file." Prune is explicitly not a bank op; it is the file-lifecycle op,
and its entire job is deletion. The spec must state this asymmetry loudly so a
reviewer never reads prune as violating the bank-op rule: **prune is the sole
file-deletion authority; a bank op that deletes a file is still a bug.**
- **Referenced-set is the union across *all* banks, pool included.** A file is an
orphan iff **no** bank in the book references it. Because copy means one file can
be referenced by several banks, prune must union references across the whole
book before deciding — deleting a file still referenced by "Drums" because it
left the pool would be catastrophic. The referenced-set computation is the
safety-critical core and the thing to test hardest (the null test of prune:
*prune never deletes a file that any index references*).
- **Relative-paths-only / project-relative resolution:** prune enumerates and
deletes within the project bank folder using the same M4 project-relative path
resolution the index uses. It must resolve the *same* way the index does, or it
could mis-identify orphans across a Save-As relocation. Prune runs against the
*resolved current* bank folder, never a stale absolute path.
- **Collapse-by-hash:** irrelevant to prune's decision (prune works on files and
references, not hashes) but worth noting: two index entries that collapsed onto
one file mean one file, multiple references — prune's union handles this for
free (the file is referenced, so it survives).
- **Determinism / bit-identical / null-test (capture):** untouched — prune sits
below the capture path entirely, same as multi-bank.
### Guardrails — this is the genuinely destructive act
Prune deletes real bytes irreversibly (a deleted `.wav` is gone unless it went to
an OS trash — see fork R-C). It earns the strongest guardrails in the product:
- **Dry-run first, always.** Prune should *report before it deletes*: "12 files
(34 MB) are referenced by no bank. Delete them?" A prune that silently sweeps is
unacceptable for an irreversible file-delete. The dry-run (compute-and-report,
the pure core with no deletion) is arguably the *primary* surface, and the
actual deletion is the confirmed second step. This mirrors how every safe
garbage-collector / disk-cleaner works (npm prune --dry-run, git gc reporting,
Lightroom's "delete rejected photos" confirmation).
- **Confirm with a manifest.** The confirmation names the count and the reclaimed
size and — for a small set — the files. The user approves a *specific* deletion,
not an abstract "clean up."
- **Never touch a referenced file, and never touch a non-bank file.** Prune's
scope is *files in the bank folder that the book once owned and no longer
references*. A file that was never a bank file (a user dropped something into the
folder by hand) is out of scope — prune should only reclaim files it can
attribute to the bank system's own leavings, not act as a general folder cleaner.
(This is a fork — R-D — because "how does prune know a file was ever ours"
depends on whether we track a manifest of owned files.)
- **Recoverability via the OS trash (fork R-C, settled: trash-preferred).** Prune
routes deletions to the platform recycle bin / trash where a portable move-to-trash
is available, so an accidental prune is recoverable outside the app; it falls back
to unlink (behind the dry-run + confirm guardrail) only where the platform affords
no portable trash. Whether SWELL / the platform layer gives us that portable "move
to trash" is a to-verify per platform — but the *default* is the safest deletion
the platform affords, and "delete where possible" means recoverable-trash-preferred,
never plain unlink-by-default.
### Where it lives — and why it is its own phase, not a Phase B point
Prune is **not** a Phase B point. Three reasons it earns its own lettered phase
(proposed **Phase R — Reclaim / file lifecycle**):
1. **It is a different pillar.** Phase B is the *bank container* pillar
(index-only, non-destructive, above the file). Prune is the *file lifecycle*
pillar (the one path that deletes files). CONTEXT.md already names it as a
separate concern every time it says "the capture/**prune** path" — file
lifecycle is spoken of as its own thing, owned by neither the capture nor the
bank layer. Giving it its own phase matches how the spec already talks about it.
2. **It serves more than Phase B.** Orphans are produced by delete-bank *and*
sample-remove (B5) *and*, arguably, by M10 re-capture superseding an old file,
*and* by Design View's deleted-track residual files if any exist. Prune is the
downstream reclaim for *all* file-orphaning paths, not a Phase-B-internal
cleanup. A capability multiple pillars forward-reference should not be nested
inside one of them.
3. **It carries a new risk class and new invariants.** Every phase so far has been
non-destructive-to-files by construction. Prune is the first phase that deletes
files, so it needs its own invariant section (the prune null test, the
referenced-set union, the dry-run guarantee, trash-routing). Burying that under
a Phase B checkbox would hide the one genuinely destructive capability in the
product inside a phase whose headline invariant is "non-destructive." The
dissonance alone argues for separation.
Lettered, per the established convention (`M` = capture pillar, `D` = Design View,
`B` = Banks): **`R` = Reclaim.** It reads correctly — Phase R is "the file
lifecycle pillar," not "a bank sub-step."
**Sequencing:** Phase R depends on B1/B2 (it needs the multi-bank book to compute
the referenced-set union across all banks) and on B5 conceptually (sample-remove
is a primary orphan-producer, so prune is most useful once remove exists), but it
does not depend on the B3/B4 *UI*. It can land any time after the book exists;
practically it should follow B5 so the two removal verbs ship as a coherent pair
(remove-then-prune is the workflow, mirroring evacuate-then-delete).
---
## Settled forks (Daniel, 2026-07-24)
### Sample-remove
**Fork R-A — remove scope. SETTLED: THIS BANK (this-bank-primary).**
Remove drops the entry from the bank in view only, leaving copies in other banks
untouched. This is the core (and shipped) verb.
- *from-this-bank* is the composable primitive (it is literally the
`BankIndex::remove` the model already has); "from everywhere" is then "remove
from each bank that holds it," which the user can also achieve by removing per
bank. It matches the partition mental model (fork 3): a sample is in one bank, so
remove-from-this-bank usually *is* remove-from-everywhere.
- **Decision:** ship **from-this-bank** as B5's core verb and the only surfaced
affordance. Keep the `scope: this-bank | all-banks` seam in the action signature
as designed, but **this-bank is the settled default and the only shipped verb**;
all-banks stays a *latent parameter*, not a surfaced convenience — it can be
promoted later behind that seam without a rewrite if the copy workflow proves to
scatter samples in practice. (Settled 2026-07-24, confirming the product-designer
lean; from-everywhere is explicitly *not* elevated to a co-equal verb now.)
**Fork R-B — undo model for index mutations (Phase-B-wide, surfaced by remove).
SETTLED: BATCH UNDO POINTS (option (iii) — REAPER-integrated, batched).**
REAPER's undo stack does not natively cover `"reasampler"` ext-state index
mutations, so move/copy/evacuate/delete-bank/remove needed an undo story. Daniel
chose to integrate bank/index mutations into **REAPER's own undo system as batched
undo points** — the `Undo_BeginBlock` / `Undo_EndBlock` direction — batching the
related index mutations of one bank operation into a single undo point, so a bank
operation is one Ctrl-Z. The considered alternatives:
- *(i)* Accept no undo (rely on confirmations + files surviving) — **rejected**, too
weak once remove destroys an index entry with no relocation.
- *(ii)* A ReaSampler-internal single-snapshot "undo last bank change" — **rejected**
in favour of the more integrated (iii); the earlier product-designer lean toward
(ii) was overridden.
- *(iii)* **CHOSEN** — hook REAPER's undo system properly, batching related index
mutations into single undo points.
- **Scope — Phase-B-wide.** This is decided for **all of Phase B at once**, and it
**retro-touches B1B4**, not just B5: every index verb (create/rename/reorder/
delete-bank, move, copy, evacuate, remove) wraps its mutation in an undo block.
Surfaced with B1's open questions, not only at B5.
- **Must-verify-before-build (carry-forward).** The whole approach depends on
`"reasampler"` ext-state mutations participating correctly in
`Undo_BeginBlock`/`Undo_EndBlock` undo blocks. **Confirm against
`vendor/reaper-sdk` that ext-state changes are captured/restored by REAPER undo
blocks before building** — if they are not, the batched-undo-point approach does
not hold and the decision must be revisited. Flagged as a hard prerequisite.
(Settled 2026-07-24.)
### Prune
**Fork R-C — deletion mechanism: unlink vs. OS trash. SETTLED: TRASH-PREFERRED,
UNLINK FALLBACK.** Prune routes deletions to the platform recycle bin / trash
(recoverable outside the app) **wherever the platform affords a portable
move-to-trash**, and falls back to unlink — behind the dry-run + confirm guardrail —
only where it does not. Trash is the settled default; "delete where possible" reads
as *recoverable-trash-preferred*, never plain unlink-by-default.
- **To-verify (carried, per platform):** whether a portable move-to-trash exists via
SWELL, or must be hand-rolled per platform — Win `SHFileOperation`/`IFileOperation`,
macOS `NSFileManager trashItemAtURL:`, Linux XDG trash spec. The move-to-trash
surface is an explicit to-verify before use, not an assumed capability. (Settled
2026-07-24.)
**Fork R-D — orphan attribution: manifest-tracked vs. index-diff vs.
folder-sweep. SETTLED: OWNED-FILE MANIFEST — and the seam lands EARLY (Phase B /
capture).** The book tracks the set of files it has created; prune reclaims
`(owned ∩ on-disk) referenced`. The considered alternatives:
- *(i) folder-sweep* — reclaim every unreferenced file in the folder. **Rejected**
it would delete a user's hand-placed file, violating "only reclaim our own
leavings."
- *(ii) index-diff only* — record a file's identity when its *last* index reference
drops and prune only that set. Safe but partial (misses files orphaned outside a
tracked drop path). Not chosen.
- *(iii) owned-file manifest* — **CHOSEN.** Safest and most general: distinguishes
"our orphan" from "user's file" and from "already-gone."
- **Seam lands early (accepted design-the-seam-now call).** The manifest is cheap to
maintain from capture onward but a **backfill cliff** to reconstruct later — you
cannot tell, after the fact, which folder files were ever ours. Daniel accepted the
recommendation to **start the owned-file manifest at capture time NOW, in Phase B,
even though prune (which consumes it) ships in Phase R.** So: **capture writes each
file it creates into an owned-file manifest persisted in the `"reasampler"`
ext-state**, and Phase R's R1/R2 *consume* that manifest. The exact persistence
shape — a sibling ext-state key vs. folded into the `banks` blob — is a small
residual to settle at build; the **manifest-now decision is firm**. (Settled
2026-07-24; the up-front point is added to Phase B / the capture path in PLAN.md.)
**Fork R-E — prune trigger: manual-only vs. offer-on-orphaning vs. periodic.
SETTLED: MANUAL ACTION + PANEL BUTTON.** Prune runs via a bindable manual action
(dry-run-first, confirm-to-delete) **and** a button in the `bank_panel` that fires
that same action. No background sweep. The earlier optional "…and prune now at the
delete-bank confirmation" convenience was **not** selected — it is dropped from the
settled spec (explicitly out of scope). A periodic/background sweep remains rejected
(silent irreversible file-deletion violates the guardrails). So R3 gains a
`bank_panel` button affordance alongside the action registration. (Settled
2026-07-24.)
---
## Summary of the boundary (for the spec)
| | Sample-remove (B5) | Delete-bank (B1/B3, shipped-spec) | Prune (Phase R) |
|---|---|---|---|
| Object | one `Sample` entry | one bank + its member entries | files on disk |
| Side of the line | index | index | **file** |
| Deletes bytes? | no | no | **yes (only op that does)** |
| Produces orphans? | yes (last-ref) | yes (non-empty) | — (it *reclaims* them) |
| Reversible? | Ctrl-Z (batched undo, R-B) / re-capture | Ctrl-Z (batched undo, R-B) / re-create | **no in-app** (recoverable via OS trash, R-C) |
| Guardrail | confirm on last-ref | confirm on non-empty | dry-run + manifest confirm |