Scope sample-remove (B5) and prune (Phase R) into the plan

Adds the two missing removal capabilities: sample-level remove (Phase B
point B5, index-only) and the prune file-lifecycle path (new Phase R, the
sole file-deleting op), resolving the dangling capture/prune reference.
Adds CONTEXT.md spec, PLAN.md points, and a product note; surfaces forks
R-A..R-E for decision.
This commit is contained in:
2026-07-23 20:21:50 -04:00
parent b9046b2842
commit b931dada0a
3 changed files with 776 additions and 10 deletions
+274
View File
@@ -731,3 +731,277 @@ and the settled-decision prose above). One panel-polish detail remains open.
- **Active-bank indicator placement (B4 polish)** — per-region headers vs. a single
header readout vs. lit-tab treatment. The "visually unmistakable" requirement is
settled (fork 4); only the placement is open. Panel-polish detail.
---
# Sample removal — additive spec (Phase B, point B5)
> **Additive section, part of the Multi-bank pillar.** The sample-level companion
> to move/copy/evacuate/delete-bank: a verb that **drops a `Sample`'s index entry**
> from a bank (or the pool). Index-only, non-destructive to the file — it sits on
> the same side of the index/file line as every other Phase B op. Product framing:
> `docs/product/removal-and-prune.md` §Sample-remove. Same verify discipline:
> **verify every REAPER API name/signature against the SDK header before use.**
## What it is
Move, copy, and evacuate all keep a sample *somewhere*; there was no verb to drop
a sample outright. **Sample-remove** is that verb: it removes one `Sample` entry
from one `BankIndex`. It exposes the `remove` primitive `bank_model`'s `BankIndex`
**already has** — B5 wires it to an action + a panel affordance, it does not add a
model capability.
## Settled decisions (spec-level)
- **Remove is index-only.** It removes the `Sample` from a `BankIndex` and mutates
only index + ext-state. No file is written, moved, or deleted; no timeline item
is touched. Identical non-destructive posture to move/copy/evacuate/delete-bank.
- **Remove can orphan a file — the same designed orphaned-until-prune state a
non-empty delete-bank produces.** When remove drops the *last* index reference to
a file (no other bank holds its hash), that file becomes an orphan on disk,
referenced by no bank, reclaimed later by **prune** (Phase R) — never by remove.
This is not a new hazard class; it is the existing "files persist until prune"
window, reached by a sample-level verb instead of a bank-level one.
- **Collapse-by-hash is unaffected.** Remove targets a specific entry in a specific
bank. Because cross-bank dedup is deliberately not enforced, removing a sample
from one bank leaves any same-hash entry in another bank intact — the same
coexistence copy relies on.
- **The pool's *contents* are removable; the pool *container* is not.** Pool
privileges (un-deletable, un-renamable, un-evacuable) govern the pool as a
container. Individual samples **can** be removed from the pool — otherwise the
pool would be a one-way trap. Remove-from-pool is the pool's own "drop this
sample" verb and is allowed.
- **Remove scope (fork R-A, OPEN — needs Daniel).** Whether "remove" drops the
entry from *this* bank only (leaving copies elsewhere), purges the sample from
*every* bank, or offers both. Spec-of-record until settled: **remove-from-this-
bank** is the core verb (it is the model primitive and composes into
from-everywhere); design the action to carry a `scope: this-bank | all-banks`
parameter so from-everywhere is a later flag, not a rewrite. See product notes
§Fork R-A.
## Precision / invariant implications
- **Non-destructive** extends to remove verbatim: index + ext-state only, no file
touched, no timeline item touched.
- **Relative-paths-only** is unaffected — remove deletes an entry, it adds no path
handling.
- **Determinism / bit-identical / null-test (capture)** untouched — remove sits
above the file, same as all of multi-bank.
## Guardrails
- **Confirm on last-reference remove; don't confirm otherwise.** A remove that
drops the *last* index reference to a file orphans it (until prune) — confirm
that case, naming the consequence ("…its file remains on disk until pruned"). A
remove of a sample still referenced by another bank is cheap and re-derivable
(re-copy it back) and needs no confirmation. The confirmation is *earned by
actual orphan risk*, not fired on every remove.
- **Undo (fork R-B, OPEN — shared across all of Phase B).** REAPER's undo stack
does not cover `"reasampler"` ext-state mutations, so remove — like
move/copy/evacuate/delete-bank — is not on REAPER's Ctrl-Z as specced. The undo
model is a Phase-B-wide decision, not a remove-local one; see product notes
§Fork R-B. Surfaced here because remove is the first verb whose *only* effect is
index-entry destruction with no relocation, so it is where the gap first bites.
## Module architecture (preserve the pure/shell split)
- `bank_book` / `BankIndex` (pure) — expose remove of a `Sample` from a bank's
index (the existing `BankIndex::remove` primitive, surfaced through the book);
pool contents removable, pool-container privileges unchanged.
- `actions` (entry) — "remove selected sample(s) from bank" (and, under fork R-A,
a scope parameter); registered with the `command_id`/`gaccel`/`hookcommand`
contract; MIDI-bindable to suit the capture-heavy workflow.
- `bank_panel` (affordance) — remove on the current selection (menu entry / key),
reusing the M5 selection model exactly as move/copy do; confirm-on-last-reference
at this layer.
## REAPER API surface
No new REAPER API. Pure model + a new action command-id string under the sampler
family prefix + a panel affordance on the existing M5 LICE surface. Verify the
command-id/gaccel/hookcommand usage against `main.cpp` (unchanged contract).
## Open questions
- **Fork R-A — remove scope (this-bank | all-banks | both).** Open; needs Daniel.
Spec-of-record: this-bank primary with a scope-parameter seam. Product notes
§Fork R-A.
- **Fork R-B — undo model for index mutations.** Open; a Phase-B-wide decision
surfaced by remove. Product notes §Fork R-B.
---
# Prune — file-lifecycle spec (Phase R — Reclaim)
> **New pillar, its own lettered phase.** Prune is the file-lifecycle path the
> capture and multi-bank specs forward-reference throughout ("files persist on disk
> until prune", "the capture/prune path reclaims it") but that had no phase, module,
> or point until now. It is the **only** operation in ReaSampler that deletes bytes
> off disk. Namespaced **`R` (Reclaim)** alongside `M` (capture), `D` (Design View),
> `B` (Banks) — it is a distinct pillar, not a Multi-bank sub-step, because it
> serves *every* orphan-producing path (delete-bank, sample-remove, re-capture) and
> carries a new risk class (file deletion) with its own invariants. Product framing
> and the phase-placement justification: `docs/product/removal-and-prune.md` §Prune.
> Same discipline: **verify every REAPER/SWELL/filesystem API name/signature against
> the SDK/SWELL headers before use.**
## What it is
Over a project's life, delete-bank and sample-remove (and, potentially, M10
re-capture superseding an old file) leave `.wav` files on disk that no bank index
references — the "orphaned-until-prune" state the specs design in on purpose.
**Prune is the reclaim pass**: reconcile the physical bank folder against the union
of every bank's index, and reclaim the files nothing references. It makes good on
the promise the rest of the spec keeps making.
## The load-bearing rule
> **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 that no index references.**
> A bank op that deletes a file is still a bug — prune is not a bank op, it is the
> file-lifecycle op.
This asymmetry is deliberate and must be stated loudly: every *other* invariant
says "no operation deletes a file." Prune is the sole, explicit exception, and its
entire job is deletion — so it must be the *only* file-deleting authority in the
system, with the strongest guardrails.
## Mirror of `reconcile` — the pure pattern one level down
Prune reuses the shape Design View already shipped. `view_mode_model`'s
`ViewModeModel::reconcile(liveGuids)` reconciles *membership entries* against *live
tracks* and returns the residuals to drop. **Prune reconciles *files on disk*
against *referenced files*** (the union of every bank's index) and returns the
orphan set to delete. Same pure pattern, one level down (files instead of GUIDs).
The **decision is pure and unit-tested**: given the set of files present in the
bank folder and the set of files referenced by the book, compute the orphan set.
Only the two ends touch the shell — *enumerating* the bank folder and *deleting*
the orphans are filesystem I/O. Keep the "which files are orphans" core REAPER-free
and hard-tested (this is the safety-critical part); keep the I/O thin. Same
pure/shell split as `bank_model` / `view_mode_model` / `bank_book`.
## Settled decisions (spec-level)
- **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 lets one file be
referenced by several banks, prune must union references across the whole book
before deciding. This is the safety-critical computation — the **prune null
test** is *prune never deletes a file that any index references.*
- **Project-relative resolution, current folder.** Prune enumerates and deletes
within the project bank folder using the **same M4 project-relative path
resolution** the index uses, against the *resolved current* folder — never a
stale absolute path — so a Save-As relocation cannot cause it to mis-identify or
mis-target orphans.
- **Dry-run first, always.** Prune reports before it deletes: the orphan count,
reclaimed size, and (for a small set) the files. The dry-run — compute-and-report,
the pure core with no deletion — is the primary surface; actual deletion is the
confirmed second step. A prune that silently sweeps is unacceptable for an
irreversible file-delete.
- **Scope is the bank system's own leavings, not the folder at large.** Prune
reclaims files that *were* bank files and are now unreferenced — never a file a
user hand-dropped into the folder. How prune attributes ownership is fork R-D
(below). Prune is a reclaimer of ReaSampler's own orphans, not a general folder
cleaner.
## Precision / invariant implications
- **The single intentional exception to "no operation deletes files."** Stated
above; called out again here so the invariant table is honest: prune is
destructive-to-files *by design and by exclusive authority*.
- **Relative-paths-only / Save-As machinery reused** — prune resolves paths the
same way the index does (M4), so it inherits relative-path correctness and
Save-As survival; it introduces no new path handling.
- **Determinism / bit-identical / null-test (capture)** untouched — prune sits
below the capture path entirely.
- **Prune null test (new invariant):** a prune of a folder whose every file is
referenced by some bank deletes nothing; a prune deletes exactly the
`present referenced` orphan set and nothing else. Ship as a tested property of
the pure core.
## Guardrails — the genuinely destructive act
- **Dry-run + confirm-with-manifest** (above): the user approves a *specific*
deletion (count + size + files), never an abstract "clean up."
- **Never a referenced file; never a non-bank file.** The union-across-all-banks
rule protects referenced files; the ownership-attribution rule (fork R-D)
protects hand-dropped files.
- **Safest platform deletion available (fork R-C, OPEN — needs Daniel + verify).**
Route deletions to the platform recycle bin / trash if a portable move-to-trash
is available (recoverable outside the app); fall back to unlink with the dry-run +
confirm guardrail if not. Default to the safest deletion the platform affords.
- **Manual, explicit trigger (fork R-E, OPEN).** Prune is a deliberate action the
user invokes, dry-run-first — never a silent background sweep. An optional "…and
prune now" convenience may be offered at the delete-bank confirmation; a periodic
background sweep is rejected (silent irreversible file-deletion violates the
guardrails).
## Module architecture (preserve the pure/shell split)
Pure (no REAPER types, unit-tested — the mirror of `reconcile`):
- **Prune-reconcile core** — given `{ files present in the bank folder }` and
`{ files referenced by the book }` (and, per fork R-D, `{ files the book owns }`),
compute the orphan set. REAPER-free, filesystem-free, unit-tested hard (the prune
null test lives here). The referenced-set is unioned across all banks by asking
the `bank_book`.
REAPER-facing / filesystem-facing (thin):
- `persist` / session — supplies the referenced-set (union across the book) and,
per fork R-D, the owned-file manifest; resolves the current project bank folder
via the M4 project-relative machinery.
- A **prune shell** — enumerates the bank folder (filesystem I/O), feeds the
pure core, presents the dry-run manifest, and on confirmation deletes the orphan
set (via trash if available — fork R-C — else unlink). Filesystem I/O only; the
decision stays in the pure core.
- `actions` (entry) — "Prune bank folder" (dry-run-first, confirm-to-delete);
registered with the `command_id`/`gaccel`/`hookcommand` contract.
## REAPER / platform API surface (verify all signatures)
No new REAPER *audio* API. New surfaces to verify before use:
- **Filesystem enumeration + delete** — directory listing and file removal for the
project bank folder. **Verify** the portable approach against SWELL / the existing
file-handling in `persist` / `capture` (which already resolve and write files);
prefer reusing whatever path/file machinery M4 established.
- **Move-to-trash (fork R-C)** — if adopted, verify a portable move-to-trash
exists (SWELL, or per-platform: Win `IFileOperation`/`SHFileOperation`, macOS
`NSFileManager trashItemAtURL:`, Linux XDG trash spec). This is a **to-verify**,
not an assumed capability.
- **Persistence (fork R-D, if owned-file manifest adopted)** — a new tracked set in
the `"reasampler"` ext-state (or folded into the `banks` blob); shared M4 blob
machinery, new data only.
- **Actions** — the `command_id`/`gaccel`/`hookcommand` contract from `main.cpp`
(unchanged), a new command-id string under the sampler family prefix.
## Non-goals / guardrails
- **Prune is the ONLY file-deletion authority.** No bank op, no capture op, no
Design View op deletes a file. If any path other than prune deletes a bank file,
reject it in review.
- **No general folder cleaning.** Prune reclaims the bank system's own unreferenced
leavings, not arbitrary files a user placed in the folder (fork R-D governs the
attribution).
- **No silent deletion.** Dry-run + explicit confirm always; no background sweep.
- **No file deleted while any index references it.** The referenced-set union
across all banks is the safety-critical invariant — enforce and test it in the
pure core, not just the UI.
- **Additive only.** Prune reads the book and the folder; it does not modify
`BankIndex`, `bank_book`, the capture roadmap, or Design View semantics.
## Open questions to resolve during build
- **Fork R-C — deletion mechanism: OS trash vs. unlink.** Open; needs Daniel + a
platform to-verify. Lean: trash if portably available, else unlink with strong
dry-run/confirm. Product notes §Fork R-C.
- **Fork R-D — orphan attribution: owned-file manifest vs. index-diff vs.
folder-sweep.** Open; needs Daniel. Lean: **owned-file manifest**, and — because
the manifest is cheap to maintain from capture onward but a backfill cliff to
reconstruct later — **start tracking owned files as part of capture / Phase B
even though prune ships in Phase R** (defer the feature, design the seam).
Folder-sweep is rejected as unsafe (deletes hand-dropped files). Product notes
§Fork R-D.
- **Fork R-E — trigger: manual-only vs. offer-on-orphaning vs. periodic.** Open.
Lean: manual-primary (dry-run-first), an optional "…and prune now" at the
delete-bank confirmation, no background sweep. Product notes §Fork R-E.
+148 -10
View File
@@ -118,23 +118,34 @@ landed milestone.
> spec. Product framing: `docs/product/design-view.md` §Two-canvas direction. When a
> point lands, doc-keeper moves it to `COMPLETED.md`.
>
> **D2-W1 (pure lane extension) and D2-W2 (shell: lane application + new-content detection) have landed** — see `COMPLETED.md`.
> **D2-W1 (pure lane extension), D2-W2 (shell: lane application + new-content
> detection), and D2-W3-A (lane minting + item→lane assignment + persist
> round-trip) have landed** — see `COMPLETED.md`.
## D2-W3 — actions, persist wiring, panel UI
**Goal:** Any new lane/mode-management actions, the persist slice serializing the
lane-ownership index alongside the membership index, and any panel UI indicator.
## D2-W3-Btwo-canvas actions + panel UI indicators
**Goal:** Any new lane/mode-management actions (bindable in the Actions list), and
any panel UI indicator for lane/mode state. The persist slice and the
lane-ownership index round-trip were completed in D2-W3-A; this wave closes the
remaining UI and action surface.
See CONTEXT.md §Two-canvas sub-phase (Module architecture — persistence).
**Verify (in DAW):** The lane-ownership index survives Save / Save As / reopen
(rides in the `"reasampler"` `view_state` alongside the membership index);
lane/mode-management actions are registered and bindable.
**Depends on:** D2-W2.
**Verify (in DAW):** Lane/mode-management actions are registered and bindable in
the Actions list; the panel UI indicator reflects the current lane/mode state.
**Depends on:** D2-W3-A.
- [ ] Any new lane/mode-management actions (`command_id`/`gaccel`/`hookcommand`);
bindable in the Actions list.
- [ ] Persist slice: serialize/deserialize the lane-ownership index in the
`"reasampler"` `view_state` section, alongside the membership index.
- [ ] Any panel UI indicator for lane/mode state.
**D2-W3-A code-review polish (fold into this wave):**
- [ ] Simplify the redundant per-mint `I_NUMFIXEDLANES` re-read in `view.cpp`
(`applyMintPlan`): a single grow-and-track pass removes the second
`GetMediaTrackInfo_Value` call inside the mint loop.
- [ ] Optional shared item-read helper to remove duplicated `itemGuid`/`itemLaneName`
read logic between `view.cpp` and `bank_panel.cpp`.
- [ ] Optional defensive note/check in `reconcileManagedLanes` for the edge case
where a managed lane name encodes an unregistered mode id (log and skip rather
than silently record an orphaned ownership entry).
---
# Phase B — Multi-bank (parallel to the M0M11 capture roadmap and Phase D)
@@ -254,6 +265,34 @@ and product notes → *Fork 5 — settled*.)
index-only and reversible). **Verify the drag hit-test doesn't collide with the M5
grid's multi-select drag.**
## B5 — sample-remove (the missing sample-level verb)
**Goal:** Drop an individual `Sample`'s index entry from a bank or the pool —
the sample-level companion to move/copy/evacuate/delete-bank. Index-only,
non-destructive to the file; exposes the `BankIndex::remove` primitive that
`bank_model` already has (wires it, does not add it). CONTEXT.md §Sample removal.
Product framing + open forks: `docs/product/removal-and-prune.md` §Sample-remove.
**Verify (in DAW):** Remove drops the selected sample's entry from the target
bank; a same-hash entry in another bank is untouched (no cross-bank dedup);
pool *contents* are removable while pool-container privileges hold; removing the
last index reference to a file leaves that file on disk (orphaned until prune —
never deleted by remove); non-destructive (index + ext-state only, no file, no
timeline item).
**Depends on:** B1, B2, B3 (action set), B4 (panel affordance).
- [ ] Surface `BankIndex::remove` through `bank_book`: remove a `Sample` from a
bank's index; pool contents removable, pool-container privileges unchanged.
- [ ] "Remove selected sample(s)" action (`command_id`/`gaccel`/`hookcommand`),
MIDI-bindable; carries a `scope: this-bank | all-banks` seam (fork R-A — default
this-bank until Daniel settles).
- [ ] `bank_panel` remove affordance on the current selection (reuse M5 selection
model, as move/copy do).
- [ ] Confirm-on-last-reference guardrail: remove that orphans a file (no other
bank references it) confirms, naming the orphaned-until-prune consequence;
remove of a still-referenced sample does not confirm.
- [ ] Tests: remove drops the target entry; same-hash entry in another bank
survives; remove-from-pool allowed; last-reference remove leaves an orphan (file
untouched); non-destructive (no file/timeline mutation).
## Phase B open questions
All five forks settled by Daniel (2026-07-23): persistence key = fold pool into `banks`,
retire legacy key (1a); delete drops members + add evacuate verb (2); move is the
@@ -265,3 +304,102 @@ ready to scope into implementation waves. One polish detail remains:
- **Active-bank indicator placement** — per-region headers vs. single header readout
vs. lit-tab. "Unmistakable" is settled; only placement is open. Polish detail.
(touches B4)
- **B5 sample-remove forks (NEW, unsettled — need Daniel):** **R-A** — remove scope
(this-bank | all-banks | both); spec-of-record is this-bank-primary with a
scope-parameter seam. **R-B** — undo model for `"reasampler"` ext-state index
mutations (affects *all* of Phase B, surfaced by remove; move/copy/evacuate/
delete-bank/remove are not on REAPER Ctrl-Z as specced). Lean: a single-snapshot
ReaSampler-internal "undo last bank change." Both in `docs/product/removal-and-prune.md`.
---
# Phase R — Reclaim (file lifecycle: the prune path)
> **New pillar, own lettered namespace.** Prune is the file-lifecycle path the
> capture and multi-bank specs forward-reference throughout ("files persist on disk
> until prune") but that had no phase, module, or point. It is the **only** operation
> in ReaSampler that deletes bytes off disk. Namespaced **`R` (Reclaim)** alongside
> `M`/`D`/`B` because it is a distinct pillar — it serves *every* orphan-producing
> path (delete-bank, sample-remove B5, potentially M10 re-capture), not just
> Multi-bank, and it carries a new risk class (file deletion) with its own
> invariants. Authoritative spec: **CONTEXT.md §Prune — file-lifecycle spec**.
> Product framing + phase-placement justification + forks:
> `docs/product/removal-and-prune.md` §Prune. When a point lands, doc-keeper moves it
> to `COMPLETED.md`.
>
> **Boundary (load-bearing):** *remove creates orphans; prune reclaims them.* No
> operation other than prune deletes a file; prune deletes only files no index
> references. A bank op that deletes a file is still a bug.
>
> **Depends on:** B1, B2 (needs the multi-bank book to union the referenced-set
> across all banks) and B5 conceptually (sample-remove is a primary orphan-producer,
> so remove-then-prune is the coherent pair — mirror of evacuate-then-delete). Does
> **not** depend on the B3/B4 UI.
## R1 — prune-reconcile core (pure)
**Goal:** REAPER-free, filesystem-free reconciler — given the set of files present
in the bank folder and the set of files referenced by the book (unioned across all
banks, pool included), compute the orphan set. The mirror of
`ViewModeModel::reconcile(liveGuids)`, one level down (files instead of GUIDs).
CONTEXT.md §Prune (Module architecture — pure).
**Verify:** CTest green. **Prune null test:** a folder whose every file is
referenced deletes nothing; prune returns exactly `present referenced` and
nothing else. Referenced-set unioned across every bank (a file referenced by any
bank — including via a copy — is never an orphan).
- [ ] Prune-reconcile pure function: `(present, referenced) → orphans`, referenced
unioned across the whole book (copies keep a file alive).
- [ ] Tests: prune null test (all-referenced → empty); orphan = presentreferenced;
a copied file referenced by a second bank survives; empty folder / empty book edge
cases.
## R2 — prune shell + persist wiring (filesystem I/O, thin)
**Goal:** Enumerate the current project bank folder (M4 project-relative resolution),
supply the referenced-set (and, per fork R-D, the owned-file set) from the session,
feed the pure core, and produce a dry-run manifest. No deletion in this wave — the
report path only. CONTEXT.md §Prune (persist / prune shell).
**Verify (in DAW):** Dry-run reports the orphan count + reclaimed size (+ file list
for a small set) against the resolved current bank folder; resolves paths the same
way the index does (survives a Save-As relocation); deletes nothing.
**Depends on:** R1, B1, B2.
- [ ] Prune shell: enumerate the resolved current bank folder; feed the pure core.
- [ ] Session supplies the referenced-set (union across the book); resolve the bank
folder via the M4 project-relative machinery.
- [ ] Dry-run manifest: orphan count + reclaimed size (+ files for a small set);
**no deletion in this wave.**
## R3 — deletion + action (the destructive step, guarded)
**Goal:** The confirmed deletion step and the bindable "Prune bank folder" action:
dry-run-first, confirm-with-manifest, then reclaim the orphan set — via OS trash if
portably available (fork R-C), else unlink. CONTEXT.md §Prune (guardrails, API).
**Verify (in DAW):** "Prune bank folder" reports first, deletes only on explicit
confirm, and reclaims exactly the orphan set — never a referenced file, never a
hand-dropped non-bank file; the referenced/owned-set safety holds; non-bank and
capture invariants untouched.
**Depends on:** R2. **Forks R-C / R-D / R-E must be settled before this wave.**
- [ ] "Prune bank folder" action (`command_id`/`gaccel`/`hookcommand`),
dry-run-first, confirm-to-delete.
- [ ] Deletion mechanism (fork R-C): OS trash if a portable move-to-trash is
verified available, else unlink with the dry-run/confirm guardrail. **Verify the
platform move-to-trash surface before use.**
- [ ] Orphan attribution (fork R-D): reclaim only the bank system's own leavings,
not hand-dropped folder files (owned-file manifest per the lean — see below).
- [ ] Trigger (fork R-E): manual-primary; optional "…and prune now" offered at the
delete-bank confirmation; **no** background sweep.
## Phase R open questions (unsettled — need Daniel)
- **Fork R-C — deletion mechanism (OS trash vs. unlink).** Lean: trash if portably
available (recoverable), else unlink with strong dry-run/confirm. Needs Daniel +
a platform to-verify (SWELL / per-platform trash APIs).
- **Fork R-D — orphan attribution (owned-file manifest vs. index-diff vs.
folder-sweep).** Lean: **owned-file manifest**. **Design-the-seam call:** the
manifest is cheap to maintain from capture onward but a backfill cliff to
reconstruct later — so **start tracking owned files as part of capture / Phase B
now, even though prune ships in Phase R.** Folder-sweep rejected as unsafe (deletes
hand-dropped files). Needs Daniel (touches the persisted shape + ideally lands
earlier than R3).
- **Fork R-E — trigger (manual-only vs. offer-on-orphaning vs. periodic).** Lean:
manual-primary + optional delete-time "prune now"; no background sweep. Needs Daniel.
+354
View File
@@ -0,0 +1,354 @@
# 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). Sample-remove forks R-A/R-B and
prune forks R-C/R-D/R-E are surfaced below; **none are settled** — they need a
Daniel call before B5 / Phase R implementation.
---
## 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 know about ext-state index mutations.
Whether remove is undoable — and how — is a genuine question shared with the
rest of Phase B (see fork R-B); it is not remove-specific, but remove is where
it bites first because remove is the first verb whose *only* effect is
destruction of an index entry with no relocation.
### 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, if we can get it (fork R-C).** If prune can
route deletions to the platform recycle bin / trash instead of unlinking, an
accidental prune is recoverable outside the app. Whether SWELL/the platform
layer gives us a portable "move to trash" is a to-verify — but the *default*
should be the safest deletion the platform affords.
### 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).
---
## Open forks — need a Daniel decision
### Sample-remove
**Fork R-A — remove scope: from-this-bank vs. from-everywhere vs. both.**
Does "remove sample" drop the entry from the bank in view (leaving copies in other
banks), purge every index entry across the whole book, or offer both as distinct
verbs?
- *from-this-bank* is the conservative, 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.
- *from-everywhere* as a distinct verb only earns its keep once copy has scattered
a sample across several banks; then "purge this everywhere" is a real
convenience.
- **Product-designer lean:** ship **from-this-bank** as B5's core verb (it is the
model primitive, it composes, it matches the partition model), and treat
*from-everywhere* as an optional convenience layered on top *only if* the copy
workflow proves to scatter samples in practice. Design the seam (the action can
take a "scope: this-bank | all-banks" parameter) so from-everywhere is a
later flag, not a rewrite — but default the shipped affordance to this-bank.
**Needs Daniel:** confirm this-bank-primary, or elevate from-everywhere to a
first-class co-equal verb now.
**Fork R-B — undo model for index mutations (shared across Phase B, surfaced by
remove).** REAPER's undo stack does not cover `"reasampler"` ext-state index
mutations. So move/copy/evacuate/delete-bank/**remove** are, as specced, not on
REAPER's Ctrl-Z. Options:
- *(i)* Accept it: index mutations are not undoable via REAPER undo; the safety net
is the confirmations + the fact that files survive (nothing is lost but the
entry, which the user can re-create by re-capture/re-move). Cheapest.
- *(ii)* A ReaSampler-internal one-step undo for the last bank/index mutation (a
single snapshot of the book restored on a "ReaSampler: undo last bank change"
action). Bounded, cheap-ish, covers the "oops" case for all Phase B verbs at
once.
- *(iii)* Hook into REAPER's undo system properly (`Undo_BeginBlock` /
`Undo_EndBlock` with ext-state as undo state) — most integrated, most work, needs
SDK verification that ext-state participates in undo blocks.
- **Product-designer lean:** this is bigger than remove and should be decided for
**all of Phase B at once**, not per-verb. Lean *(ii)* — a single-snapshot
internal undo of the last bank/index change — as the pragmatic middle: it turns
every index verb's "oops" into a one-key recovery without wading into REAPER's
undo internals. **Needs Daniel** (and it may retro-touch B1B4, so surfacing it
now rather than at B5 is deliberate — the seam is cheapest before B1 lands).
### Prune
**Fork R-C — deletion mechanism: unlink vs. OS trash.** Does prune `remove()` the
file (irreversible) or route to the platform recycle bin/trash (recoverable
outside the app)? Trash is the safer default; the question is whether SWELL / the
platform layer offers a portable move-to-trash, or whether we'd hand-roll it per
platform (Win `SHFileOperation`/`IFileOperation`, macOS `NSFileManager
trashItemAtURL`, Linux XDG trash spec). **Needs Daniel + a to-verify** on platform
support. Lean: **trash if portably available, unlink with a strong dry-run+confirm
guardrail if not.**
**Fork R-D — orphan attribution: manifest-tracked vs. index-diff vs.
folder-sweep.** How does prune know a file in the bank folder is a *reclaimable
orphan* (a bank file no index references) rather than *a file that was never
ours* (a user dropped it in by hand)?
- *(i) folder-sweep* — treat every file in the bank folder not referenced by the
book as an orphan and reclaim it. Simplest; **dangerous** — it will delete a
user's hand-placed file, violating "only reclaim our own leavings."
- *(ii) index-diff only* — never sweep the folder; instead, capture the file's
identity at the moment its *last* index reference is dropped (remove/delete-bank)
and prune only that recorded set. Safe (only ever touches files we know we
orphaned) but requires recording orphans-at-drop-time.
- *(iii) owned-file manifest* — the book tracks the set of files it has ever
created (capture writes to it); prune reclaims `(owned ∩ on-disk) referenced`.
Safest and most general (distinguishes "our orphan" from "user's file" and from
"already-gone"); costs a manifest that rides the ext-state.
- **Product-designer lean:** *(iii) owned-file manifest* — it is the honest
encoding of "prune reclaims only the bank system's own leavings," and it is a
**design-the-seam-now** call: the manifest is cheap to maintain from capture
onward but expensive to reconstruct retroactively (you cannot tell, later, which
folder files were ever ours). If we defer prune but *don't* start tracking owned
files now, we build a backfill cliff. **Strong recommendation: start the owned-
file manifest as part of Phase B / capture even though prune ships in Phase R** —
defer the feature, design the seam. **Needs Daniel** because it touches the
persisted shape (a new tracked set) and ideally lands earlier than Phase R.
**Fork R-E — prune trigger: manual-only vs. offer-on-orphaning vs. periodic.**
When does prune run? Manual action only (user invokes "Prune bank folder"),
prompted opportunistically (after a non-empty delete-bank: "…evacuate, or delete
and prune now?"), or a background/periodic sweep? Lean: **manual-only as the core
(an explicit, dry-run-first action the user invokes deliberately)**; an *optional*
"…and prune now" affordance offered at the delete-bank confirmation as a
convenience, never a background sweep (silent irreversible file-deletion is exactly
what the guardrails forbid). **Needs Daniel** to confirm manual-primary and
whether the delete-time "prune now" convenience is wanted.
---
## 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? | re-add/re-capture entry | re-create bank + re-add | **no** (unless trash, R-C) |
| Guardrail | confirm on last-ref | confirm on non-empty | dry-run + manifest confirm |