Merge dev into phase-b-multibank (integrate parallel M7/8 + Phase D work before dev promotion)
# Conflicts: # CLAUDE.md # CMakeLists.txt # src/actions.cpp # src/bank_panel.cpp # src/persist.h
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
# Provenance — product notes
|
||||
|
||||
Framing, rationale, and the dual-canvas reconciliation behind the reshaped
|
||||
**Milestone 10 (provenance)**. The tickable spec lives in `PLAN.md` (M10); the
|
||||
authoritative technical detail is `CONTEXT.md` (§Data model, §capture) plus this
|
||||
note for the reconciliation calls. This doc holds the *why* and the open forks so
|
||||
they don't clutter the build docs.
|
||||
|
||||
Status: **SETTLED (2026-07-23).** Reshaped from the old "provenance + null-test
|
||||
verify" M10. Two decisions were fixed by Daniel up front (see *What was cut* below).
|
||||
The four dual-canvas interaction forks are now resolved: **P1=a thin fingerprint,
|
||||
P2=a bank-only re-capture**, which makes **P3 and P4 moot (closed)**. The fork
|
||||
analysis below is retained as the rationale record — each fork is stamped with its
|
||||
resolution inline; nothing here is open.
|
||||
|
||||
---
|
||||
|
||||
## What was cut (fixed by Daniel — do not reopen)
|
||||
|
||||
- **The null-test verification ACTION is cut.** Daniel verifies bit-accuracy
|
||||
himself when he cares (he already null-tested the first capture spike, M3). The
|
||||
tool ships no null-test button.
|
||||
- **The "true pre-FX dry capture" mechanism is dropped.** The old M10 note said the
|
||||
null test would require a true pre-FX dry render (bypass-around-render or the M8
|
||||
realtime pre-FX tap). With the null-test action gone, that mechanism has no
|
||||
consumer and is dropped too. `CaptureRequest.wetDry` stays in the struct as an
|
||||
inert seam (M7 already retained it), but M10 does **not** build a dry path.
|
||||
|
||||
What survives from the old M10: **provenance + "re-capture from source,"** which
|
||||
Daniel confirmed is genuinely useful — with the added constraint that it must be
|
||||
coherent with the dual-canvas (Design View / two-canvas) architecture built in
|
||||
parallel.
|
||||
|
||||
---
|
||||
|
||||
## What provenance is (and what already exists)
|
||||
|
||||
**Provenance records where a sample came from, so a sample can be regenerated from
|
||||
its source.** The concrete case: you capture something into the bank, place it,
|
||||
process it further, and re-capture the processed result — provenance is the thread
|
||||
back from the child sample to the parent it was resampled from, plus enough about
|
||||
the capture to reproduce it.
|
||||
|
||||
**Most of the data model already exists.** `Sample` (M1, landed) already carries:
|
||||
|
||||
```
|
||||
std::optional<Provenance> provenance; // set only when resampled
|
||||
struct Provenance {
|
||||
std::string parentSampleId;
|
||||
std::string fxChainSnapshot;
|
||||
};
|
||||
```
|
||||
|
||||
and it already JSON-round-trips (M1's lossless-round-trip test covers it). So M10
|
||||
is **not** "add provenance fields" — the seam is cut. M10 is:
|
||||
|
||||
1. **Populate** `provenance` on captures that resample from an existing bank sample.
|
||||
2. **Consume** it via a "re-capture from source" action that regenerates the sample.
|
||||
3. **Reconcile** both with the dual-canvas model (the new work — see below).
|
||||
|
||||
### What `fxChainSnapshot` should mean now (given the M7 rework)
|
||||
|
||||
The old note assumed provenance would snapshot a chain for a *pre-FX dry* render.
|
||||
That's gone. Under the **shipped M7 capture model**, capture is always wet and the
|
||||
control is the **FX scope** (item = item/take FX only; track = item FX + that
|
||||
track's own track FX), with the out-of-scope chain neutralized to unity by
|
||||
`FxBypassGuard` for the render. There is no wet/dry dial.
|
||||
|
||||
So `fxChainSnapshot` should record **the capture recipe, not a dry-render chain**:
|
||||
the scope, the source range (already on `Sample.sourceRange`), the source track
|
||||
GUID(s) (already on `Sample.trackGuids`), the tail setting, and — the genuinely new
|
||||
bit — enough of the **source FX-chain identity at capture time** that "re-capture
|
||||
from source" can tell whether the source still matches what was captured. This is a
|
||||
*fingerprint for reproducibility*, not a mechanism for a different render mode.
|
||||
|
||||
> **Fork P1 — how much chain state does `fxChainSnapshot` carry? — CHOSEN: (a)
|
||||
> thin fingerprint (Daniel, 2026-07-23).** Two shapes:
|
||||
> **(a) thin fingerprint** — a hash/summary of the source scope + FX-chain identity
|
||||
> at capture time, used only to detect drift ("source has changed since capture")
|
||||
> and to re-run the *same* capture request; or **(b) fat snapshot** — a full
|
||||
> serialized FX-chain state string (`TrackFX` chunk) that re-capture could restore
|
||||
> before rendering, so the regenerated sample matches even if the user has since
|
||||
> tweaked the chain. (a) is simpler, non-destructive, and matches "re-capture
|
||||
> reflects the source as it is *now*"; (b) is heavier, mutates the live chain during
|
||||
> re-capture (a new destructive-ish surface), and re-opens some of the pre-FX-dry
|
||||
> complexity we just cut. **Lean: (a) thin fingerprint.** Re-capture-from-source
|
||||
> most naturally means "run the capture again against the source's *current* state"
|
||||
> — that's the useful workflow (I changed the source, give me the updated sample).
|
||||
> The fingerprint's job is to *tell* the user the source drifted, not to freeze it.
|
||||
> **Resolved 2026-07-23: (a). `fxChainSnapshot` is a capture-recipe fingerprint for
|
||||
> drift detection and re-run — not a serialized chain to restore.**
|
||||
|
||||
---
|
||||
|
||||
## The dual-canvas reconciliation (the real new work)
|
||||
|
||||
Daniel's constraint, verbatim: *"with the sound design canvas parallel, I think
|
||||
provenance is genuinely useful, but we should make sure it's compliant with the
|
||||
dual canvas stuff."*
|
||||
|
||||
The dual-canvas ("two-canvas," Phase D2) architecture that landed in parallel:
|
||||
Design View is a **mode projection** over one timeline reaching both **tracks**
|
||||
(D1 parking) and **items** (D2 fixed lanes). New content is **auto-tagged to the
|
||||
active mode** at creation. Membership + lane-ownership are GUID-keyed and persist
|
||||
in the `"reasampler"` `view_state` section — a **separate** pure module
|
||||
(`view_mode_model`) from the bank (`bank_model`). The settled placement rule
|
||||
(CONTEXT §Capture placement — mode-aware): *an explicit placement while in Design
|
||||
mode lands the item in the Design lane.*
|
||||
|
||||
There are exactly **four** genuine interaction points between provenance and this
|
||||
model. Each is a fork for Daniel.
|
||||
|
||||
### Where re-capture lands (the load-bearing one)
|
||||
|
||||
"Re-capture from source" regenerates a sample into the bank. Per the load-bearing
|
||||
capture principle, **regenerating the bank sample never inserts into the timeline**
|
||||
— so at the bank level there is *no* canvas interaction: the regenerated file + index
|
||||
entry land in the bank exactly as any capture does, and the bank is mode-agnostic.
|
||||
|
||||
The interaction only appears **if re-capture also re-places** the regenerated sample
|
||||
onto the timeline (replacing the old placed item). That is a *placement*, and
|
||||
placement is mode-aware under D2.
|
||||
|
||||
> **Fork P2 — does "re-capture from source" re-place, or only refresh the bank
|
||||
> entry? — CHOSEN: (a) bank-only re-capture (Daniel, 2026-07-23).** Two shapes:
|
||||
> **(a) bank-only re-capture** — regenerate the file + update the bank Sample
|
||||
> in place; the user re-places manually if they want the new version on the
|
||||
> timeline. Fully honors the load-bearing principle with zero canvas coupling;
|
||||
> simplest; matches how every other capture behaves (capture ≠ placement).
|
||||
> **(b) re-capture-and-replace** — regenerate *and* swap the placed timeline item
|
||||
> for the new file. Convenient, but it is an auto-placement path, so it must obey
|
||||
> the D2 mode-aware placement rule (lands in the active mode's lane / the original
|
||||
> item's lane) and it touches the timeline (undo block, non-destructive to
|
||||
> everything else). **Lean: (a) bank-only.** It keeps M10 inside the capture
|
||||
> pillar's clean "capture never places" line and defers all the canvas-placement
|
||||
> complexity. (b) can be a later opt-in ("re-capture and replace in place") once (a)
|
||||
> proves the provenance thread. If Daniel wants (b), P3 and P4 below become live.
|
||||
> **Resolved 2026-07-23: (a). Re-capture regenerates the file into the bank and
|
||||
> updates the Sample in place; it never places/replaces on the timeline. P3 and P4
|
||||
> are therefore moot — closed (b) can still be revisited as a later opt-in.**
|
||||
|
||||
### Does provenance need to record canvas/mode membership?
|
||||
|
||||
`Sample` (bank) and `Membership`/`LaneOwnership` (view model) are **separate pure
|
||||
modules today, by design** — the bank is mode-agnostic (a sample is just a file +
|
||||
metadata; it doesn't know it was placed in Design). The question is whether
|
||||
provenance must break that separation to record *which canvas/lane* the source item
|
||||
lived in, so re-capture can put the regenerated sample back there.
|
||||
|
||||
Under Fork P2 = (a) bank-only, the answer is **no** — re-capture doesn't place, so
|
||||
it needs no canvas memory; the bank stays mode-agnostic and the two pure modules
|
||||
stay decoupled. Under P2 = (b) re-place, the answer becomes **yes, partially**.
|
||||
|
||||
> **Fork P3 — (only live if P2 = re-place) does provenance store canvas/lane
|
||||
> membership? — CLOSED/moot under P2=a (2026-07-23).** If re-capture re-places, where
|
||||
> does it land?
|
||||
> **(a) active-mode rule** — re-placement follows the *same* D2 auto-tag/placement
|
||||
> rule as any explicit placement: it lands in whatever mode is active *now*.
|
||||
> Provenance stores **nothing** about canvas; the view model's existing rule
|
||||
> governs. Keeps `bank_model` mode-agnostic.
|
||||
> **(b) origin-lane memory** — provenance records the source item's mode/lane at
|
||||
> capture time (a GUID + mode-id or lane-key) so re-capture lands the regenerated
|
||||
> sample back in the *original* canvas regardless of the active mode. More faithful
|
||||
> to "put it back where it was," but it couples `bank_model` provenance to
|
||||
> `view_mode_model` identifiers — a cross-module reach the architecture currently
|
||||
> avoids. **Lean: (a) active-mode rule**, kept in the view model; provenance stays
|
||||
> pure bank metadata with no view-model ids. Only reach for (b) if "re-capture
|
||||
> restores the exact original lane" is a stated requirement.
|
||||
> **Closed 2026-07-23: moot under P2=a. Re-capture never places, so it stores no
|
||||
> canvas memory; provenance stays pure bank metadata and the two modules stay
|
||||
> decoupled. Revisit only if re-capture-and-replace (P2=b) is later adopted.**
|
||||
|
||||
### Re-capture and auto-tagging
|
||||
|
||||
D2 auto-tags **new** track/item GUIDs (detected by the panel-timer GUID diff)
|
||||
to the active mode. A re-placed item (P2 = b) is a *new* item GUID on the timeline,
|
||||
so it would be auto-tagged to the active mode automatically — which is exactly Fork
|
||||
P3 = (a) behavior, for free, via the existing detection path. No special-casing
|
||||
needed *unless* Daniel wants origin-lane memory (P3 = b), in which case re-capture
|
||||
must tag the new item explicitly and **suppress** the auto-tag for that GUID (or the
|
||||
two fight).
|
||||
|
||||
> **Fork P4 — (only live if P2 = re-place AND P3 = origin-lane) does re-capture
|
||||
> preserve or re-run auto-tagging? — CLOSED/moot under P2=a (2026-07-23).** If
|
||||
> provenance restores the origin lane, the
|
||||
> re-placed item must be tagged to the *origin* mode, not the active mode — so
|
||||
> re-capture has to write the membership itself and exempt that GUID from the
|
||||
> timer's auto-tag (same class as the manual-lane exemption already in
|
||||
> `autoTagNewContent`). **This fork only exists under P2=(b) + P3=(b).** Under the
|
||||
> leaned defaults (P2=a, or P2=b + P3=a) it does not arise: bank-only re-capture
|
||||
> places nothing, and active-mode re-placement rides the existing auto-tag path
|
||||
> unchanged. **Closed 2026-07-23: moot under P2=a — bank-only re-capture places
|
||||
> nothing, so no auto-tag interaction arises.**
|
||||
|
||||
### Summary of the settled path
|
||||
|
||||
Daniel took the leans (**P1=a thin fingerprint, P2=a bank-only re-capture**,
|
||||
2026-07-23), so the reconciliation collapses to almost nothing: provenance is
|
||||
**pure per-sample bank metadata**, `bank_model` and `view_mode_model` **stay
|
||||
decoupled**, and M10 touches **no** canvas code. The dual-canvas compliance is
|
||||
satisfied by *staying on the right side of the load-bearing line* (capture/re-capture
|
||||
never places), not by new coupling. P3 and P4 are moot (closed) — they were only
|
||||
live if re-capture also re-placed onto the timeline.
|
||||
|
||||
The settled shape: **keep provenance in the bank, keep re-capture a bank-only
|
||||
regenerate, and let the existing D2 placement rule handle the timeline if and when
|
||||
the user manually re-places.** It is the smallest thing that delivers the useful
|
||||
workflow and the cleanest against the architecture.
|
||||
|
||||
---
|
||||
|
||||
## Persistence / precision-invariant implications (spec-level)
|
||||
|
||||
- **Provenance is already-persisted metadata.** `Sample.provenance` already
|
||||
serializes in the `BankIndex` JSON (M1) under the existing `bank_index` /
|
||||
(post-Phase-B) `banks` ext-state key. **Populating it adds no new persistence
|
||||
surface** — the round-trip test already exercises the field. The only spec note:
|
||||
if Fork P1 grows `fxChainSnapshot` from a thin string to a fat FX-chunk (P1=b),
|
||||
the field is still one string on `Sample`, so the JSON shape is unchanged, but the
|
||||
blob gets heavier — a size consideration, not a schema one. Under P1=a (thin
|
||||
fingerprint) the field stays small.
|
||||
- **Under the leaned path, provenance touches `view_state` not at all.** No
|
||||
lane-ownership or membership data is added for provenance; the view section is
|
||||
unchanged. (Only P3=b would add view-model ids into provenance — and that would be
|
||||
the argument *against* P3=b.)
|
||||
- **Precision invariants are unaffected.** Re-capture is a capture: it produces a
|
||||
file deterministically (bit-identical repeats hold — a re-capture with an
|
||||
unchanged source and request is byte-identical to the original capture, which is
|
||||
itself a nice provenance property), it is non-destructive to source items/tracks
|
||||
(`FxBypassGuard` snapshot/restore, as M7), it honors exact bounds, and it writes
|
||||
only relative paths. **Do not design the serialization here** — this is spec-level;
|
||||
the implementer owns the JSON encoding of whatever P1 shape Daniel picks.
|
||||
- **Do not reintroduce the dry path.** The precision-invariant list in CONTEXT still
|
||||
names the null test as the "trust anchor." With the action cut, that line is now
|
||||
historical framing, not an M10 deliverable — flag for doc-keeper to reconcile when
|
||||
M10 lands, but M10 does **not** ship a null-test action or a pre-FX dry render.
|
||||
|
||||
---
|
||||
|
||||
## Decision list (settled 2026-07-23)
|
||||
|
||||
1. **P1 — `fxChainSnapshot` shape: CHOSEN (a) thin reproducibility fingerprint.**
|
||||
(Rejected: (b) fat serialized FX-chain chunk.)
|
||||
2. **P2 — re-capture scope: CHOSEN (a) bank-only regenerate.** (Rejected for now:
|
||||
(b) re-capture-and-replace-on-timeline; may return as a later opt-in.)
|
||||
3. **P3 — canvas memory: CLOSED/moot under P2=a.** Was only live under P2=b; lean
|
||||
was (a) active-mode rule, provenance stores no view ids.
|
||||
4. **P4 — auto-tag interaction: CLOSED/moot under P2=a.** Was only live under P2=b +
|
||||
P3=b.
|
||||
|
||||
Picks 1a + 2a make P3 and P4 moot and keep M10 a small, decoupled, capture-pillar
|
||||
milestone. The PLAN M10 points are locked to this path.
|
||||
@@ -0,0 +1,364 @@
|
||||
# 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 |
|
||||
Reference in New Issue
Block a user