b931dada0a
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.
355 lines
21 KiB
Markdown
355 lines
21 KiB
Markdown
# 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 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 |
|