diff --git a/CONTEXT.md b/CONTEXT.md index 465d863..569c841 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/PLAN.md b/PLAN.md index 4bccf45..ba85b36 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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-B — two-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 M0–M11 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 = present−referenced; + 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. diff --git a/docs/product/removal-and-prune.md b/docs/product/removal-and-prune.md new file mode 100644 index 0000000..5c924ff --- /dev/null +++ b/docs/product/removal-and-prune.md @@ -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 |