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.
21 KiB
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):
- 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.)
- 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
Sampleentry in a specificBankIndex(theremoveprimitivebank_modelalready 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):
- 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.
- 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.
- 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::removethe 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_EndBlockwith 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 B1–B4, 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 |