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:
@@ -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 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 |
|
||||
Reference in New Issue
Block a user