1f24c4b095
Split root CLAUDE.md into 19 per-directory files scoped to their source area. Roll v0 history into docs/ARCHIVE.md; retire CONTEXT.md, CONTEXT-ARCHIVE.md, PLAN.md, COMPLETED.md. Move plan docs under docs/. Rescue 9 live deferrals into docs/TODO.md.
367 lines
22 KiB
Markdown
367 lines
22 KiB
Markdown
# Removal & prune — product notes
|
||
|
||
Framing, rationale, and open forks behind the two missing removal capabilities:
|
||
**sample-remove** (a sample-level index verb) and **prune** (the file-lifecycle
|
||
path the spec kept forward-referencing but never scoped). The tickable spec's
|
||
landed history is in `docs/ARCHIVE.md` (Phase B point B5 for remove; **Phase R**
|
||
for prune) and the architecture detail lives in `src/core/model/CLAUDE.md` +
|
||
`src/shell/bank_ops/CLAUDE.md` (§Sample removal) and `src/core/reclaim/CLAUDE.md`
|
||
+ `src/shell/persist/CLAUDE.md` (§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
|
||
history in `docs/ARCHIVE.md` and the architecture docs above.
|
||
|
||
---
|
||
|
||
## The one boundary that governs everything: index vs. file
|
||
|
||
ReaSampler already draws a hard line: **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:
|
||
|
||
- **Removes are silent — no confirm dialog.** Recoverability is provided by the
|
||
batched REAPER undo (R-B): one Ctrl-Z restores the index entry. A last-reference
|
||
remove does orphan the file (no other bank holds it), but the file stays on disk
|
||
until an explicit prune — remove never deletes bytes. The undo path makes the
|
||
confirm unnecessary; the file-safety guarantee (orphaned-until-prune) remains
|
||
unchanged. `hashReferencedElsewhere` is a tested model API retained for Phase R
|
||
prune; it has no shell caller in the remove path.
|
||
- **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 the spec 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 the spec 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
|
||
(`src/core/view/CLAUDE.md`: "tolerates unknown/stale GUIDs (pruned on reconcile
|
||
via `ViewModeModel::reconcile(liveGuids)`)"). 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). The spec already named 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 recorded in `docs/ARCHIVE.md` under Phase B /
|
||
the capture path.)
|
||
|
||
**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 | silent (Ctrl-Z restores; files never deleted by remove) | confirm on non-empty | dry-run + manifest confirm |
|