# Removal & prune — product notes Framing, rationale, and open forks behind the two missing removal capabilities: **sample-remove** (a sample-level index verb) and **prune** (the file-lifecycle path CONTEXT.md keeps forward-referencing but never scoped). The tickable spec lives in `PLAN.md` (Phase B point B5 for remove; **Phase R** for prune) and the authoritative technical detail in `CONTEXT.md` (§Sample removal, §Prune — file lifecycle). This doc holds the *why* — the workflow, the guardrails, the index-vs-file boundary, and the forks that need a Daniel decision. Status: framed by product-designer (2026-07-23); **all five forks settled by Daniel (2026-07-24)** — R-A this-bank-primary, R-B batched REAPER undo points (Phase-B-wide), R-C trash-preferred-with-unlink-fallback, R-D owned-file manifest (seam lands early in Phase B / capture), R-E manual action + panel button. The decisions are folded into the fork sections below and into the B5 / Phase R spec prose in CONTEXT.md and the tickable points in PLAN.md. --- ## The one boundary that governs everything: index vs. file ReaSampler already draws a hard line, stated repeatedly in CONTEXT.md: **a bank operation touches the *index*, never the *file*.** Move, copy, evacuate, and delete-bank are all index-only; files persist on disk "until prune." Every removal capability below sits on exactly one side of that line, and keeping the two verbs on opposite sides is the whole design. - **Sample-remove drops an index entry.** It is the sample-level sibling of the bank verbs — move/copy/evacuate all keep the sample *somewhere*; remove is "drop this entry outright." It is **index-only, non-destructive to the file**, and — exactly like a plain delete-bank of a non-empty bank — it can *create* an orphan when it removes the last index reference to a file. It sits on the **same side of the line as every existing Phase B op.** - **Prune deletes files off disk.** It is the *only* operation in the entire system that removes bytes. It reconciles the physical bank folder against the union of all bank indices and reclaims files referenced by no bank. It sits on the **file side of the line, alone.** So the crisp statement, worth putting in the spec verbatim: > **Remove creates orphans; prune reclaims them.** Sample-remove and delete-bank > drop index entries and may leave a file referenced by nothing. Prune is the > single path that turns such an orphan back into free disk space. No other > operation deletes a file; prune deletes *only* files no index references. This is why they are two different scope objects (Phase B vs. Phase R), even though a naive reading ("both are 'delete' verbs") would lump them together. --- ## Sample-remove — the missing sample-level verb ### What the user is doing The user has a sample they no longer want in a bank (or in the pool): a bad take, a duplicate they don't want collapsed, a sample they filed into "Drums" by mistake. Today they can *relocate* it (move/copy/evacuate) but they cannot drop it. Remove is the "get this out of here" verb. Two intents hide inside it, and the distinction is a fork (R-A below): 1. **Remove from *this* bank** — drop the entry from the bank the user is looking at, leaving any copies in other banks untouched. (If the sample was copied into "Drums" and also lives in the pool, remove-from-Drums leaves the pool copy alone.) 2. **Remove from *everywhere*** — drop every index entry for this sample across all banks in one act ("purge this sample from the library"). ### Reconciling with existing invariants - **Collapse-by-hash:** unaffected. Remove operates on a specific `Sample` entry in a specific `BankIndex` (the `remove` primitive `bank_model` already has, per CLAUDE.md — B5 exposes it, it does not add it). Because dedup is per-bank and cross-bank dedup is deliberately *not* enforced, "remove from this bank" and "the same hash still lives in another bank" coexist cleanly — that is the same coexistence copy already relies on. - **Files-are-never-deleted-by-a-bank-op:** upheld. Remove is index-only, exactly like move/copy/evacuate/delete-bank. When remove drops the *last* reference to a file, it produces the **same orphaned-until-prune state** a non-empty delete-bank already produces — a designed state, not a new hazard class. The file is reclaimed later by prune, never by remove. - **Pool privileges:** the pool cannot be *deleted, renamed, or evacuated*, but individual samples **can** be removed from the pool — otherwise the pool would become a roach-motel (samples check in, never leave except by moving to a named bank). Remove-from-pool is allowed; it is the pool's own "drop this sample" verb. (The pool-as-container privilege is untouched; only its *contents* are removable.) - **Non-destructive:** remove mutates only index + ext-state, touches no file and no timeline item — the Phase B non-destructive guarantee extends to it verbatim. ### Guardrails Remove is *less* dangerous than it first looks, because it never deletes a file — the bytes survive on disk until an explicit prune. So the recovery story is: an accidental remove loses the *index entry*, not the audio. But there are two sharpnesses to guard: - **Last-reference remove is the orphan-maker.** Removing a sample that exists in only one bank orphans its file (until prune). This is the same footgun as non-empty delete-bank, and it deserves the same treatment: **confirm when the remove drops the last index reference** ("Remove 'kick_03'? It is in no other bank — its file will remain on disk until pruned."). A remove of a sample that still lives in another bank is cheap and reversible-in-spirit (re-copy it back) and need not confirm. This makes the confirmation *earned* by actual risk rather than fired on every remove. - **Undo.** REAPER's own undo stack does not natively cover ext-state index mutations, so this is a Phase-B-wide decision (fork R-B, **settled**): bank/index mutations integrate into REAPER's undo system as **batched undo points** (`Undo_BeginBlock` / `Undo_EndBlock`), so one bank operation is one Ctrl-Z. Remove is where the gap first bites — it is the first verb whose *only* effect is destruction of an index entry with no relocation — but the fix is shared by every Phase B verb. ### Where it lives Remove is a `bank_book`/`BankIndex` verb (pure), a bindable `actions` entry, and a `bank_panel` affordance on the current selection — the exact three-layer shape every Phase B verb already takes. That is why it belongs **in Phase B as B5**, not in a phase of its own: same modules, same pattern, same side of the index/file line. It is the verb Phase B forgot, not a new pillar. --- ## Prune — the file-lifecycle path CONTEXT.md kept promising ### What the user is doing The user has been working for a while: capturing, re-capturing (M10), deleting banks, removing samples. Each of those left files on disk that no index references any more — the "orphaned-until-prune" state the multi-bank spec designs in on purpose. Over a long project the bank folder accumulates dead `.wav` files that cost disk and clutter. **Prune is the reclaim pass**: "sweep the bank folder, delete the files nothing references, tell me what you reclaimed." This is the path CONTEXT.md forward-references in at least four places ("files persist on disk until prune," "the capture/prune path reclaims it") but never scopes. It is a real, promised capability with **no phase, no module, no point** — a dangling reference the plan has to make good on. ### The load-bearing precedent: prune-on-reconcile already exists for modes ReaSampler already shipped this exact shape once. Design View's `view_mode_model` has **`ViewModeModel::reconcile(liveGuids)`** — a pure function fed the live set (the tracks that still exist), returning the residual membership entries to drop (CONTEXT.md §Design View: "prunes orphaned snapshots on every toggle/load; tolerates unknown/stale GUIDs (prune on reconcile)"). Prune is the **file-pool mirror of that pure pattern**: > `reconcile(liveGuids)` reconciles *membership entries* against *live tracks*. > Prune reconciles *files on disk* against *referenced files* (the union of every > bank's index). Same shape — feed the pure core the live set, get back the > residuals — one level down (files instead of GUIDs). This is the "mirror the existing pure pattern" move the whole codebase is built on (`bank_model`, `view_mode_model`, `bank_book` are all the same pure-registry shape). The **pure part of prune** — "given the set of files on disk and the set of files referenced by the book, compute the orphan set" — is a REAPER-free, unit-testable function that belongs with the pure cores. Only the two ends are shell work: *enumerating* the bank folder (filesystem I/O) and *deleting* the orphans (filesystem I/O). Keep the decision (which files are orphans) pure and tested; keep the I/O thin. This is the same pure/shell split as everything else. ### Reconciling with existing invariants - **This is the one place the "files are never deleted" rule is *intentionally* broken — and it must be the *only* one.** Every other invariant says "no bank op deletes a file." Prune is explicitly not a bank op; it is the file-lifecycle op, and its entire job is deletion. The spec must state this asymmetry loudly so a reviewer never reads prune as violating the bank-op rule: **prune is the sole file-deletion authority; a bank op that deletes a file is still a bug.** - **Referenced-set is the union across *all* banks, pool included.** A file is an orphan iff **no** bank in the book references it. Because copy means one file can be referenced by several banks, prune must union references across the whole book before deciding — deleting a file still referenced by "Drums" because it left the pool would be catastrophic. The referenced-set computation is the safety-critical core and the thing to test hardest (the null test of prune: *prune never deletes a file that any index references*). - **Relative-paths-only / project-relative resolution:** prune enumerates and deletes within the project bank folder using the same M4 project-relative path resolution the index uses. It must resolve the *same* way the index does, or it could mis-identify orphans across a Save-As relocation. Prune runs against the *resolved current* bank folder, never a stale absolute path. - **Collapse-by-hash:** irrelevant to prune's decision (prune works on files and references, not hashes) but worth noting: two index entries that collapsed onto one file mean one file, multiple references — prune's union handles this for free (the file is referenced, so it survives). - **Determinism / bit-identical / null-test (capture):** untouched — prune sits below the capture path entirely, same as multi-bank. ### Guardrails — this is the genuinely destructive act Prune deletes real bytes irreversibly (a deleted `.wav` is gone unless it went to an OS trash — see fork R-C). It earns the strongest guardrails in the product: - **Dry-run first, always.** Prune should *report before it deletes*: "12 files (34 MB) are referenced by no bank. Delete them?" A prune that silently sweeps is unacceptable for an irreversible file-delete. The dry-run (compute-and-report, the pure core with no deletion) is arguably the *primary* surface, and the actual deletion is the confirmed second step. This mirrors how every safe garbage-collector / disk-cleaner works (npm prune --dry-run, git gc reporting, Lightroom's "delete rejected photos" confirmation). - **Confirm with a manifest.** The confirmation names the count and the reclaimed size and — for a small set — the files. The user approves a *specific* deletion, not an abstract "clean up." - **Never touch a referenced file, and never touch a non-bank file.** Prune's scope is *files in the bank folder that the book once owned and no longer references*. A file that was never a bank file (a user dropped something into the folder by hand) is out of scope — prune should only reclaim files it can attribute to the bank system's own leavings, not act as a general folder cleaner. (This is a fork — R-D — because "how does prune know a file was ever ours" depends on whether we track a manifest of owned files.) - **Recoverability via the OS trash (fork R-C, settled: trash-preferred).** Prune routes deletions to the platform recycle bin / trash where a portable move-to-trash is available, so an accidental prune is recoverable outside the app; it falls back to unlink (behind the dry-run + confirm guardrail) only where the platform affords no portable trash. Whether SWELL / the platform layer gives us that portable "move to trash" is a to-verify per platform — but the *default* is the safest deletion the platform affords, and "delete where possible" means recoverable-trash-preferred, never plain unlink-by-default. ### Where it lives — and why it is its own phase, not a Phase B point Prune is **not** a Phase B point. Three reasons it earns its own lettered phase (proposed **Phase R — Reclaim / file lifecycle**): 1. **It is a different pillar.** Phase B is the *bank container* pillar (index-only, non-destructive, above the file). Prune is the *file lifecycle* pillar (the one path that deletes files). CONTEXT.md already names it as a separate concern every time it says "the capture/**prune** path" — file lifecycle is spoken of as its own thing, owned by neither the capture nor the bank layer. Giving it its own phase matches how the spec already talks about it. 2. **It serves more than Phase B.** Orphans are produced by delete-bank *and* sample-remove (B5) *and*, arguably, by M10 re-capture superseding an old file, *and* by Design View's deleted-track residual files if any exist. Prune is the downstream reclaim for *all* file-orphaning paths, not a Phase-B-internal cleanup. A capability multiple pillars forward-reference should not be nested inside one of them. 3. **It carries a new risk class and new invariants.** Every phase so far has been non-destructive-to-files by construction. Prune is the first phase that deletes files, so it needs its own invariant section (the prune null test, the referenced-set union, the dry-run guarantee, trash-routing). Burying that under a Phase B checkbox would hide the one genuinely destructive capability in the product inside a phase whose headline invariant is "non-destructive." The dissonance alone argues for separation. Lettered, per the established convention (`M` = capture pillar, `D` = Design View, `B` = Banks): **`R` = Reclaim.** It reads correctly — Phase R is "the file lifecycle pillar," not "a bank sub-step." **Sequencing:** Phase R depends on B1/B2 (it needs the multi-bank book to compute the referenced-set union across all banks) and on B5 conceptually (sample-remove is a primary orphan-producer, so prune is most useful once remove exists), but it does not depend on the B3/B4 *UI*. It can land any time after the book exists; practically it should follow B5 so the two removal verbs ship as a coherent pair (remove-then-prune is the workflow, mirroring evacuate-then-delete). --- ## Settled forks (Daniel, 2026-07-24) ### Sample-remove **Fork R-A — remove scope. SETTLED: THIS BANK (this-bank-primary).** Remove drops the entry from the bank in view only, leaving copies in other banks untouched. This is the core (and shipped) verb. - *from-this-bank* is the composable primitive (it is literally the `BankIndex::remove` the model already has); "from everywhere" is then "remove from each bank that holds it," which the user can also achieve by removing per bank. It matches the partition mental model (fork 3): a sample is in one bank, so remove-from-this-bank usually *is* remove-from-everywhere. - **Decision:** ship **from-this-bank** as B5's core verb and the only surfaced affordance. Keep the `scope: this-bank | all-banks` seam in the action signature as designed, but **this-bank is the settled default and the only shipped verb**; all-banks stays a *latent parameter*, not a surfaced convenience — it can be promoted later behind that seam without a rewrite if the copy workflow proves to scatter samples in practice. (Settled 2026-07-24, confirming the product-designer lean; from-everywhere is explicitly *not* elevated to a co-equal verb now.) **Fork R-B — undo model for index mutations (Phase-B-wide, surfaced by remove). SETTLED: BATCH UNDO POINTS (option (iii) — REAPER-integrated, batched).** REAPER's undo stack does not natively cover `"reasampler"` ext-state index mutations, so move/copy/evacuate/delete-bank/remove needed an undo story. Daniel chose to integrate bank/index mutations into **REAPER's own undo system as batched undo points** — the `Undo_BeginBlock` / `Undo_EndBlock` direction — batching the related index mutations of one bank operation into a single undo point, so a bank operation is one Ctrl-Z. The considered alternatives: - *(i)* Accept no undo (rely on confirmations + files surviving) — **rejected**, too weak once remove destroys an index entry with no relocation. - *(ii)* A ReaSampler-internal single-snapshot "undo last bank change" — **rejected** in favour of the more integrated (iii); the earlier product-designer lean toward (ii) was overridden. - *(iii)* **CHOSEN** — hook REAPER's undo system properly, batching related index mutations into single undo points. - **Scope — Phase-B-wide.** This is decided for **all of Phase B at once**, and it **retro-touches B1–B4**, not just B5: every index verb (create/rename/reorder/ delete-bank, move, copy, evacuate, remove) wraps its mutation in an undo block. Surfaced with B1's open questions, not only at B5. - **Must-verify-before-build (carry-forward).** The whole approach depends on `"reasampler"` ext-state mutations participating correctly in `Undo_BeginBlock`/`Undo_EndBlock` undo blocks. **Confirm against `vendor/reaper-sdk` that ext-state changes are captured/restored by REAPER undo blocks before building** — if they are not, the batched-undo-point approach does not hold and the decision must be revisited. Flagged as a hard prerequisite. (Settled 2026-07-24.) ### Prune **Fork R-C — deletion mechanism: unlink vs. OS trash. SETTLED: TRASH-PREFERRED, UNLINK FALLBACK.** Prune routes deletions to the platform recycle bin / trash (recoverable outside the app) **wherever the platform affords a portable move-to-trash**, and falls back to unlink — behind the dry-run + confirm guardrail — only where it does not. Trash is the settled default; "delete where possible" reads as *recoverable-trash-preferred*, never plain unlink-by-default. - **To-verify (carried, per platform):** whether a portable move-to-trash exists via SWELL, or must be hand-rolled per platform — Win `SHFileOperation`/`IFileOperation`, macOS `NSFileManager trashItemAtURL:`, Linux XDG trash spec. The move-to-trash surface is an explicit to-verify before use, not an assumed capability. (Settled 2026-07-24.) **Fork R-D — orphan attribution: manifest-tracked vs. index-diff vs. folder-sweep. SETTLED: OWNED-FILE MANIFEST — and the seam lands EARLY (Phase B / capture).** The book tracks the set of files it has created; prune reclaims `(owned ∩ on-disk) − referenced`. The considered alternatives: - *(i) folder-sweep* — reclaim every unreferenced file in the folder. **Rejected** — it would delete a user's hand-placed file, violating "only reclaim our own leavings." - *(ii) index-diff only* — record a file's identity when its *last* index reference drops and prune only that set. Safe but partial (misses files orphaned outside a tracked drop path). Not chosen. - *(iii) owned-file manifest* — **CHOSEN.** Safest and most general: distinguishes "our orphan" from "user's file" and from "already-gone." - **Seam lands early (accepted design-the-seam-now call).** The manifest is cheap to maintain from capture onward but a **backfill cliff** to reconstruct later — you cannot tell, after the fact, which folder files were ever ours. Daniel accepted the recommendation to **start the owned-file manifest at capture time NOW, in Phase B, even though prune (which consumes it) ships in Phase R.** So: **capture writes each file it creates into an owned-file manifest persisted in the `"reasampler"` ext-state**, and Phase R's R1/R2 *consume* that manifest. The exact persistence shape — a sibling ext-state key vs. folded into the `banks` blob — is a small residual to settle at build; the **manifest-now decision is firm**. (Settled 2026-07-24; the up-front point is added to Phase B / the capture path in PLAN.md.) **Fork R-E — prune trigger: manual-only vs. offer-on-orphaning vs. periodic. SETTLED: MANUAL ACTION + PANEL BUTTON.** Prune runs via a bindable manual action (dry-run-first, confirm-to-delete) **and** a button in the `bank_panel` that fires that same action. No background sweep. The earlier optional "…and prune now at the delete-bank confirmation" convenience was **not** selected — it is dropped from the settled spec (explicitly out of scope). A periodic/background sweep remains rejected (silent irreversible file-deletion violates the guardrails). So R3 gains a `bank_panel` button affordance alongside the action registration. (Settled 2026-07-24.) --- ## Summary of the boundary (for the spec) | | Sample-remove (B5) | Delete-bank (B1/B3, shipped-spec) | Prune (Phase R) | |---|---|---|---| | Object | one `Sample` entry | one bank + its member entries | files on disk | | Side of the line | index | index | **file** | | Deletes bytes? | no | no | **yes (only op that does)** | | Produces orphans? | yes (last-ref) | yes (non-empty) | — (it *reclaims* them) | | Reversible? | Ctrl-Z (batched undo, R-B) / re-capture | Ctrl-Z (batched undo, R-B) / re-create | **no in-app** (recoverable via OS trash, R-C) | | Guardrail | confirm on last-ref | confirm on non-empty | dry-run + manifest confirm |