Skip to main content

max / audiofiles

cleanup orphans menu + sample-deletion design doc Two pieces of work toward the multi-device sample-deletion story: (1) Settings → Storage gets a "Cleanup orphans" button. Calls a new Backend::cleanup_orphans_local that wraps remove_orphaned_samples in `applying_remote='1'` trigger suppression so the local cleanup doesn't push DELETE samples rows over sync. Without the suppression, every device that received the cleanup would CASCADE-wipe its own placements (the load-bearing concern from the 2026-06-02 SyncKit audit). Each device manages its own orphan set today; tomorrow's tombstone design keeps this property. (2) docs/design-sample-deletion.md captures the full design for multi-device sample deletion as a tombstone column on `samples` with a 30-day retention window, sync-replicated, and a Trash UI. Single-device delete was already correct (placement-only); the open problem was sync-pull `DELETE samples` cascading on receiving devices. The design picks F (tombstone) over E (per-device orphan management) because the product model is "my library, accessible from any of my devices" — deletion should mean "gone everywhere, recoverable." Five-phase rollout: M019 + read-path filter → delete+undelete ops → Trash UI → background sweep → pull notification. Each phase is one session. Not implemented yet; the doc is the contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-03 03:42 UTC
Commit: ed6636fa2e77a8cf1885e263012a3aa96406fab9
Parent: dad6bdf
6 files changed, +274 insertions, -2 deletions
@@ -627,6 +627,34 @@ impl Backend for DirectBackend {
627 627 Ok(self.store.remove_orphaned_samples(&db)?)
628 628 }
629 629
630 + fn cleanup_orphans_local(&self) -> BackendResult<usize> {
631 + // Flip `sync_state.applying_remote` to '1' so the sync DELETE
632 + // triggers don't push the orphan removals over the wire. The
633 + // current behavior of pushing them (inherited from the M007 trigger
634 + // design) would cascade-wipe placements on every synced device —
635 + // exactly the surprise the planned tombstone work
636 + // (docs/design-sample-deletion.md) is meant to fix.
637 + //
638 + // Local cleanup is the right semantics today: each device has its
639 + // own set of orphans (samples it no longer references), and
640 + // collecting disk space is a local operation, not a cross-device
641 + // statement. Set + run + reset in a single connection scope so a
642 + // panic during the cleanup can't leave the flag stuck at '1'.
643 + let db = self.db.lock();
644 + db.conn()
645 + .execute(
646 + "UPDATE sync_state SET value = '1' WHERE key = 'applying_remote'",
647 + [],
648 + )
649 + .map_err(|e| BackendError::Other(format!("flip applying_remote: {e}")))?;
650 + let result = self.store.remove_orphaned_samples(&db);
651 + let _ = db.conn().execute(
652 + "UPDATE sync_state SET value = '0' WHERE key = 'applying_remote'",
653 + [],
654 + );
655 + Ok(result?)
656 + }
657 +
630 658 fn sample_source_path(&self, hash: &str) -> BackendResult<Option<String>> {
631 659 let db = self.db.lock();
632 660 Ok(audiofiles_core::store::sample_source_path(&db, hash)?)
@@ -353,6 +353,14 @@ pub trait Backend: Send + Sync {
353 353 /// Remove samples no longer referenced by any VFS node. Returns count removed.
354 354 fn remove_orphaned_samples(&self) -> BackendResult<usize>;
355 355
356 + /// User-initiated local orphan cleanup. Wraps `remove_orphaned_samples`
357 + /// with sync trigger suppression (`applying_remote='1'`) so the cleanup
358 + /// stays local to this device — other devices keep their own copies of
359 + /// the samples until they independently determine the samples are
360 + /// orphaned. Forward-compatible with the planned tombstone-based
361 + /// sample-deletion design (see `docs/design-sample-deletion.md`).
362 + fn cleanup_orphans_local(&self) -> BackendResult<usize>;
363 +
356 364 /// Look up the source_path for an loose-files mode sample. Returns None for normal samples.
357 365 fn sample_source_path(&self, hash: &str) -> BackendResult<Option<String>>;
358 366
@@ -102,6 +102,24 @@ impl BrowserState {
102 102 }
103 103 }
104 104
105 + /// User-initiated local orphan cleanup. Wraps the backend call's
106 + /// trigger suppression so the cleanup doesn't push DELETE samples
107 + /// rows over sync to other devices. Returns silently on the empty
108 + /// case; on success surfaces a count in the status line.
109 + pub fn cleanup_orphans_now(&mut self) {
110 + match self.backend.cleanup_orphans_local() {
111 + Ok(0) => self.status = "No orphaned samples to clean up.".to_string(),
112 + Ok(n) => {
113 + self.status = format!(
114 + "Removed {n} orphaned sample{}.",
115 + if n == 1 { "" } else { "s" }
116 + );
117 + self.refresh_contents();
118 + }
119 + Err(e) => self.status = format!("Cleanup failed: {e}"),
120 + }
121 + }
122 +
105 123 /// Dismiss the first-launch hint and persist the preference.
106 124 pub fn dismiss_first_launch_hint(&mut self) {
107 125 self.show_first_launch_hint = false;
@@ -273,6 +273,24 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) {
273 273 );
274 274 }
275 275
276 + // Cleanup orphans: free disk by removing samples no longer
277 + // referenced by any VFS placement. Sync triggers are
278 + // suppressed for this operation (local-only by design — each
279 + // synced device curates its own orphan set).
280 + ui.add_space(theme::space::SM);
281 + ui.horizontal(|ui| {
282 + if ui
283 + .button("Cleanup orphans")
284 + .on_hover_text(
285 + "Free disk by deleting samples no longer referenced anywhere in the library. \
286 + Local-only: other synced devices keep their own copies.",
287 + )
288 + .clicked()
289 + {
290 + state.cleanup_orphans_now();
291 + }
292 + });
293 +
276 294 ui.add_space(theme::space::MD);
277 295 ui.separator();
278 296 ui.add_space(theme::space::SM);
@@ -0,0 +1,199 @@
1 + # Sample deletion — tombstone design (proposal)
2 +
3 + **Status:** proposal, 2026-06-02. Not yet implemented.
4 +
5 + **Author:** Max / Claude (audit session)
6 +
7 + **Scope:** the multi-device sync semantics of deleting a sample. Single-device delete is already correct (placement-only via `vfs_nodes` delete; sample row untouched). This doc covers the open question: what happens when a `samples` row deletion needs to propagate.
8 +
9 + ---
10 +
11 + ## Problem
12 +
13 + Today, `apply_remote_changes` in `crates/audiofiles-sync/src/service/resolve.rs` applies a remote `DELETE samples WHERE hash=X` directly. SQLite enforces `ON DELETE CASCADE` on `vfs_nodes.sample_hash`, `tags.sample_hash`, and `collection_members.sample_hash` at the engine level (not via triggers), so `applying_remote='1'` does not suppress it. The receiving device loses every placement, every tag, and every collection membership of that sample — silently, without confirmation.
14 +
15 + The user's mental model for an audiofiles library is "my library, accessible from any of my devices, hand-picked launch cohort" (launch plan, 2026-06-01). The current behavior matches the implementation of "global library, but delete on one device wipes everything on every device with no record." Two specific surprises:
16 +
17 + - **Device A deletes a sample they no longer want** → Device B (which had organized that sample into 3 collections and tagged it across 12 placements) loses all that work without warning.
18 + - **Device A runs an automated cleanup** (today: VFS-delete sweep; tomorrow: the new "Cleanup orphans" menu without the `applying_remote` guard we just added) → all of A's placements gone → push → B's placements gone too.
19 +
20 + The path forward is **soft-delete with a tombstone column**, replicating across devices, with a recovery window before hard delete. This document is the design contract before implementation.
21 +
22 + ---
23 +
24 + ## Non-goals
25 +
26 + - **Cross-device "is this sample still in use anywhere?" awareness.** That requires a server-side reference count, which the current sync model (encrypted blob storage + opaque changelog) explicitly does not have. Each device sees only its own references plus the tombstone state.
27 + - **Per-VFS or per-collection delete isolation.** Removing a sample from one collection is already handled by `collection_members` delete (no sample deletion involved). Same for VFS placements.
28 + - **Reversing already-shipped purges.** Any `samples` row already deleted before this design lands is gone for good — the sync layer has no way to ask other devices "did you keep a copy."
29 + - **Server-side garbage collection of orphaned blobs.** Blob lifecycle on the cloud is a separate concern; this design only changes the DB row lifecycle.
30 +
31 + ---
32 +
33 + ## Design
34 +
35 + ### Schema (M019)
36 +
37 + Add a column to `samples`:
38 +
39 + ```sql
40 + ALTER TABLE samples ADD COLUMN deleted_at INTEGER;
41 + ```
42 +
43 + `NULL` means live. A non-NULL Unix timestamp means tombstoned at that wall-clock instant. The column is nullable and indexed (`CREATE INDEX idx_samples_deleted_at ON samples(deleted_at) WHERE deleted_at IS NOT NULL;`) for the eventual sweep query.
44 +
45 + ### CASCADE policy change
46 +
47 + `vfs_nodes.sample_hash REFERENCES samples(hash) ON DELETE CASCADE` stays as-is. The CASCADE only fires when the row is **hard-deleted** (post-tombstone-window sweep). For the soft-delete path, the `samples` row stays, so no CASCADE fires; placements remain; user can recover.
48 +
49 + `tags.sample_hash` and `collection_members.sample_hash` likewise unchanged. Hard delete still cleans up cleanly.
50 +
51 + ### Read-path update surface
52 +
53 + Every `SELECT FROM samples` (or join through `samples`) gains a `WHERE samples.deleted_at IS NULL` filter unless the caller is explicitly working with tombstoned rows (Trash view, sweep query, undelete). Estimated surface area (preliminary grep, needs verification during implementation):
54 +
55 + - `crates/audiofiles-core/src/store.rs` — every `sample_path`, `sample_extension`, dedup check
56 + - `crates/audiofiles-core/src/analysis/decode.rs`, `waveform.rs` — analysis pipeline
57 + - `crates/audiofiles-core/src/search/*` — search queries
58 + - `crates/audiofiles-core/src/vfs.rs` — enriched VFS queries (join to samples)
59 + - `crates/audiofiles-core/src/collections.rs` — collection member queries
60 + - `crates/audiofiles-core/src/tags.rs` — tag queries
61 + - `crates/audiofiles-core/src/fingerprint.rs`, `similarity.rs` — near-duplicate detection
62 + - `crates/audiofiles-browser/src/backend/direct.rs` — Backend trait surface
63 +
64 + Total ballpark: 30-50 query sites. Most are mechanical (add a clause). A few need a paired explicit-tombstone variant (e.g., the Trash view).
65 +
66 + A `is_tombstoned(hash) -> bool` helper in `core::store` is the natural shape for ad-hoc checks.
67 +
68 + ### Delete operation
69 +
70 + "Delete sample" (the explicit user gesture or the cleanup-orphans menu, post-design) becomes:
71 +
72 + ```sql
73 + UPDATE samples SET deleted_at = unixepoch() WHERE hash = ?1 AND deleted_at IS NULL;
74 + ```
75 +
76 + That fires the existing `sync_samples_update` trigger (which already exists post-M018), pushing the row to `sync_changelog`. Receiving device pulls the UPDATE, sees `deleted_at` is now set, and:
77 +
78 + 1. Locally marks the sample as tombstoned (sets its own `deleted_at` to the received value if currently NULL).
79 + 2. Surfaces a "Sample deleted on another device: NAME (12 placements affected)" notification.
80 + 3. Optionally provides an "Undelete on this device" button that re-sets `deleted_at` to NULL locally (creating a temporary divergence until reconciled).
81 +
82 + For the local-only case (the "Cleanup orphans" menu we just shipped): same UPDATE, but trigger-suppressed via `applying_remote='1'`. Other devices never learn about it.
83 +
84 + ### Sweep
85 +
86 + A background task on app startup (or scheduled, e.g., once per day) hard-deletes rows where `deleted_at < unixepoch() - 30*86400`. That triggers the CASCADE, fires the sync DELETE trigger, propagates the actual deletion. Hard-delete window: **30 days**, configurable via `user_config` (`sample_tombstone_retain_days`).
87 +
88 + Tradeoff: a longer window means more recoverability but more disk consumed by tombstoned-but-still-stored blobs. The 30 days matches macOS Trash defaults.
89 +
90 + ### UI states
91 +
92 + - **Live sample** in any view: no visual change from today.
93 + - **Tombstoned sample** in normal browse: hidden by the read-path filter.
94 + - **Trash view**: new entry in the Settings panel showing all tombstoned samples grouped by deletion date. Each row offers "Undelete" (clear `deleted_at`) and "Delete permanently" (skip the retention window, hard-delete now).
95 + - **Pull notification**: when a remote tombstone is applied, surface a status message: "12 samples tombstoned by another device — see Trash to recover."
96 +
97 + ### Sync semantics summary
98 +
99 + | Operation | What it pushes | What other devices see |
100 + |---|---|---|
101 + | Local placement delete (`vfs_nodes` row) | `vfs_nodes` DELETE | That one placement removed locally |
102 + | User-initiated sample delete | `samples` UPDATE (deleted_at set) | Sample tombstoned locally, placements preserved, notification |
103 + | Local "Cleanup orphans" | nothing (sync triggers suppressed) | unchanged |
104 + | Sweep after 30d | `samples` DELETE | Same — CASCADE removes their placements too |
105 + | Undelete on either device | `samples` UPDATE (deleted_at cleared) | Sample restored everywhere |
106 +
107 + The 30-day window means a sweep on Device A propagates as a hard delete to Device B 30 days after A's tombstone — at which point B has also had 30 days to undelete if they cared. Both sides converge.
108 +
109 + ### Conflict resolution
110 +
111 + - Both devices independently delete the same sample at different times: the **earlier** `deleted_at` wins (sync conflict resolution uses min(deleted_at) on UPDATE conflicts).
112 + - Device A undeletes (sets NULL) while Device B's tombstone is in flight: NULL wins (undelete is the destructive-on-tombstone action).
113 + - Hard delete on A while B has undeleted: A's CASCADE wipes A's placements; B keeps theirs and pushes a re-INSERT of `samples`. A re-pulls, gets the sample back. Edge case: A's blob may be gone; falls back to `cloud_only` semantics.
114 +
115 + ### `cloud_only` interaction
116 +
117 + Today, `cloud_only` marks samples whose local blob has been evicted but exist in cloud storage. With tombstones:
118 +
119 + - Tombstoned samples are NOT automatically marked `cloud_only`. The blob stays on disk during the retention window so undelete is instant.
120 + - After the sweep hard-deletes the `samples` row, the blob is removed from disk too (existing `SampleStore::remove` path).
121 + - If a remote re-INSERT arrives after a local hard delete (the "Device B undeleted after A swept" edge), the new `samples` row gets `cloud_only=1` set automatically by the pull-side logic (TBD: add to `apply_upsert` for samples table when the local blob is missing).
122 +
123 + ---
124 +
125 + ## Migration (M019)
126 +
127 + ```sql
128 + ALTER TABLE samples ADD COLUMN deleted_at INTEGER;
129 + CREATE INDEX IF NOT EXISTS idx_samples_deleted_at
130 + ON samples(deleted_at) WHERE deleted_at IS NOT NULL;
131 +
132 + -- Sync trigger update: existing samples triggers already emit hash + the
133 + -- full row; they pick up the new column automatically via json_object.
134 + -- No trigger recreation needed.
135 +
136 + INSERT OR IGNORE INTO user_config (key, value) VALUES ('sample_tombstone_retain_days', '30');
137 + ```
138 +
139 + The trigger bodies already serialize `NEW.*` columns by name, so the new `deleted_at` field flows through `sync_changelog` automatically once the migration is applied.
140 +
141 + ---
142 +
143 + ## Test plan
144 +
145 + 1. **Tombstone basic flow:** mark a sample tombstoned, assert it's hidden from `list_vfs_contents`, undeletable, sweepable.
146 + 2. **Sync replication:** mock a pull that applies a `samples` UPDATE with `deleted_at` set; assert local row is marked tombstoned; assert placements remain.
147 + 3. **Sweep:** insert a tombstoned sample with `deleted_at` 31 days in the past; run sweep; assert hard delete + CASCADE happened.
148 + 4. **Undelete-after-pull-tombstone:** apply remote tombstone; clear `deleted_at` locally; assert the sample is live again locally and a sync push is queued with `deleted_at=NULL`.
149 + 5. **Conflict: both devices tombstone at different times:** apply two UPDATEs in succession; assert min(deleted_at) wins.
150 + 6. **Read-path coverage:** for every Backend method that returns sample data, assert tombstoned rows are excluded by default and included when an explicit `include_tombstoned` flag is set.
151 +
152 + ---
153 +
154 + ## Rollout phasing
155 +
156 + **Phase 1 — M019 + read-path filter** (1 session). Land the schema, add the `WHERE deleted_at IS NULL` filter across all read sites. Tombstones don't surface in UI yet; behavior change is invisible to users (everything is still NULL).
157 +
158 + **Phase 2 — delete & undelete operations** (1 session). Wire the UPDATE path. Replace the current `SampleStore::remove` callers with `tombstone_sample` (a new method). The cleanup-orphans menu shipped 2026-06-02 keeps its local-only semantics but switches from hard delete to tombstone + immediate hard delete (since orphan = not referenced, no recovery value).
159 +
160 + **Phase 3 — Trash UI** (1 session). New Settings section listing tombstoned samples with undelete + delete-permanently affordances.
161 +
162 + **Phase 4 — sweep** (1 session). Background task on app startup hard-deletes past-retention tombstones. Sync push of the resulting `samples` DELETE rows; receiving devices CASCADE their own data (which, by symmetry, has also been tombstoned for >= 30 days).
163 +
164 + **Phase 5 — sync notification** (1 session). On pull, count incoming tombstones and surface a one-shot status: "X samples deleted on another device — see Trash to recover."
165 +
166 + Phases 1-2 are required for correctness. Phases 3-5 are UX polish that can land in any order after Phase 2.
167 +
168 + ---
169 +
170 + ## Open questions
171 +
172 + - **Should tags also be tombstoned?** A tag rename today goes through DELETE+INSERT (composite-PK constraint). If we tombstone samples but not tags, a sample's tag entries vanish at sweep time even if the sample is recovered before sweep. Likely acceptable — tags are derived metadata; the sample's `original_name`, analysis, and placements are the load-bearing recovery.
173 + - **Window default — 30 days, or shorter?** 30 matches OS Trash conventions. Shorter (7) saves disk; longer (60+) maximizes recovery. Probably a `user_config` knob with default 30.
174 + - **Hard-delete sync semantics post-sweep:** should the sweep push a single "tombstone-expired" event rather than a `samples` DELETE, so receiving devices that haven't swept yet know to skip their own sweep? Or just let each device sweep independently? Independent sweep is simpler and converges correctly.
175 + - **What if a device is offline for > retention window?** It receives a hard-delete for samples it still has live placements on. The "Sample deleted on another device" notification probably needs to gain "and the retention window expired, your placements are also being removed" semantics. Surface count + offer Trash recovery before applying. Open detail for Phase 5.
176 + - **Server-side tombstone awareness:** the server doesn't decrypt `data`, so it can't see `deleted_at`. Tombstones are application-level; server stores opaque encrypted blobs. The sync push of a tombstone is just another encrypted update; the server has no special handling needed. Good — keeps the privacy model intact.
177 +
178 + ---
179 +
180 + ## Why not E (per-device orphan management)
181 +
182 + The "each device manages its own purges; samples DELETE never propagates as global destroy" model is lighter (no schema change), but creates an awkward asymmetry: "delete on Device A doesn't propagate to Device B." This breaks the stated product model ("my library, accessible from any of my devices") — users expect "I deleted this" to mean it's gone from their library, not just from this device. E only works for a federation model (collaborator A and B each curate their own subset of a shared blob pool) which isn't what audiofiles is.
183 +
184 + ## Why not G (confirmation-only with current CASCADE)
185 +
186 + G doesn't fix the sync-pull surprise. A confirm dialog on local delete is helpful but doesn't help Device B when Device A purges. The sync-pull cascade still wipes B's placements without B ever seeing a dialog. G is at best an additional safety net under either E or F, not a substitute.
187 +
188 + ---
189 +
190 + ## Acceptance criteria
191 +
192 + This design is implemented when:
193 +
194 + - A sample marked `deleted_at` is invisible from every Backend method that returns sample data, unless explicitly opted-in.
195 + - Sync push of the tombstone replicates the `deleted_at` field; receiving device marks the same sample as tombstoned without losing placements.
196 + - A user can browse the Trash view, see their tombstoned samples, and either undelete or delete permanently.
197 + - A 30-day-old tombstone is automatically hard-deleted on next app startup.
198 + - The "Cleanup orphans" menu (already shipped 2026-06-02) is local-only and continues to work; its eventual replacement uses the tombstone path with immediate hard-delete (no retention for things you never placed).
199 + - No existing tests fail; the new behavior is covered by tests per the Test plan section.
M todo.md +3 -2
@@ -34,7 +34,7 @@ Launch shipped 2026-06-01 (see `/Users/max/Code/launchplan_final.md`). Post-laun
34 34 - [x] **egui 0.34 deprecation cleanup** — full sweep: `App::update` → `App::ui` (new trait shape, `ui: &mut Ui` instead of `ctx`); `SidePanel`/`TopBottomPanel` → `Panel::left`/`Panel::right`/`Panel::top`/`Panel::bottom`; `Panel::show(ctx,...)` → `show_inside(ui,...)` across 26 sites; `Memory::toggle_popup`/`close_popup` → `Popup::toggle_id`/`close_id`; `popup_below_widget` → `Popup::from_response`; `show_tooltip_at_pointer` → `Tooltip::always_open`; `Context::style`/`set_style` → `global_style`/`set_global_style`; `Context::screen_rect` → `content_rect`; `Ui::close_menu` → `Ui::close`; `cpal DeviceTrait::name` → `description().name()`. Required signature changes to `draw_browser` and all leaf `draw_*` screen fns (configure/progress/tagging/export/activation/vault_setup/db_error) from `ctx: &Context` → `ui: &mut Ui`. 0 deprecation warnings remaining; 269 tests pass.
35 35
36 36 ### Repo hygiene (launchplan §2.3)
37 - - [ ] Remove `crates/audiofiles-app/tests/harness/mod.rs.bak` if it ever reappears (deleted this session as part of the fix commit).
37 + - [x] `crates/audiofiles-app/tests/harness/mod.rs.bak` confirmed absent 2026-06-02.
38 38 - [x] **Audit `docs/` for stale plans.** Subagent-graded 2026-06-02: `database_schema.md` rewritten for the 12→18 migration count + M013/M015/M018 schema (source_path, filter_json, smart_folders dropped, row_id hashing, row_id_salt). `architecture.md` updated for the same plus the m4a/alac/caf/bwf extension list and the atomic-write helper in §Export System. `description.md` swapped "Smart Folders" for "Dynamic Collections" + added Cmd+, About entry. `troubleshooting.md` fixed migration count, sync table list, row_id_salt note, and a wrong mirror path (`~/.audiofiles-mirror/` → `~/audiofiles-mirror/`). `design-system.md`, `loose-files-mode.md`, `ml_classifier.md`, `plugin_authoring.md`, `trial-mode.md` were graded CURRENT and left alone.
39 39 - [x] **CONTRIBUTING.md walkthrough.** Tables-synced list updated (smart_folders out, edit_history + user_config in). Added a Sync Changelog Triggers paragraph on M018 row_id hashing + canonical-PK-in-data, with guidance for new synced tables. Added a replay-safety paragraph in the Database section (`IF NOT EXISTS` / `DROP IF EXISTS` / `INSERT OR IGNORE`, exempting M001/M002, pointing at the regression test) and a note on the `hash_row_id` SQLite function. Build commands table at the end was already accurate.
40 40
@@ -59,5 +59,6 @@ Launch shipped 2026-06-01 (see `/Users/max/Code/launchplan_final.md`). Post-laun
59 59 - Added `migration_replay_from_file_no_op` and `migration_replay_from_version_two_against_full_schema` tests. The replay test rolls `PRAGMA user_version=2` against a populated schema and re-runs every migration from M003 onward; future non-idempotent CREATEs will fail this test loudly.
60 60 - Documented why M001 (initial schema) and M002 (table-rebuild dance with `DROP TABLE tags; ALTER tags_v2 RENAME TO tags`) are inherently one-shot and not replay-safe.
61 61 - **Recovery branch validator landed 2026-06-02.** Replaced the `tracing::warn!` + silent-bump path with fail-fast: on any non-ALTER recovery failure that isn't "already exists", roll back and surface the error. This immediately surfaced a real latent bug — M007 created sync triggers on `smart_folders`, which M015 drops, so post-M015 replay parsed-failed on M007. Fixed by removing the smart_folders triggers from M007 (M015's `DROP TRIGGER IF EXISTS` stays for already-installed DBs). M015 itself is now documented as inherently one-shot alongside M001/M002 (the backfill `SELECT FROM smart_folders` can't parse on replay against a populated post-M015 schema; SQLite has no conditional-execute). Replay test rolls `user_version` back to 15 (post-M015) rather than 2; the realistic recovery scenario is "re-apply the one migration that crashed", not "re-apply every migration from scratch". Added `migrate_recovery_branch_fails_fast_on_non_alter_error` and `migrate_recovery_branch_tolerates_already_exists`.
62 - - **Deferred, design discussion:** `vfs_nodes.sample_hash ON DELETE CASCADE` silently wipes every VFS placement of a sample on delete. Orphan-to-tombstone may be safer for multi-device sync.
62 + - **Design landed 2026-06-02:** sample-deletion semantics for multi-device sync are designed in `docs/design-sample-deletion.md` (F: tombstone column on `samples`, 30-day retention, sync-replicated, Trash UI). Single-device delete was already correct (placement-only); the load-bearing concern was sync-pull `DELETE samples` cascading globally on the receiving device. Phasing in the doc (M019 + read-path filter → delete+undelete ops → Trash UI → sweep → notification). Five phases, single session each. Implementation deferred to next sessions.
63 + - **Local-only orphan cleanup shipped 2026-06-02:** Settings → Storage → "Cleanup orphans" button calls a new `Backend::cleanup_orphans_local` that wraps `remove_orphaned_samples` in `applying_remote='1'` trigger suppression. Forward-compatible with the tombstone design (Phase 2 will replace the underlying call with `tombstone_sample` + immediate hard-delete for orphans).
63 64 - **Deferred, historical:** M015 backfill INSERT into `collections` fires `sync_collections_insert` from M007 → emits spurious `sync_changelog` rows on devices that ran M015. Has already shipped. Future migrations should wrap data backfills with `UPDATE sync_state SET value='1' WHERE key='applying_remote'` and reset on commit.