# The `orphans` provider — a store garbage-collection view ## Context and problem statement The content-addressed store (`architecture/crabidy-store.md`) never shrinks on its own. Capturing writes audio + a `.cbd-store.toml` sidecar into `~/.local/share/crabidy/`; deleting a save (or the `current` queue rolling over, or a `scan --capture` toml being removed) only ever removes the *toml that pointed at* a store entry — never the entry itself (store `D7`). That is deliberate: a store entry may be shared by many tomls, so no single deletion can know it is safe to reclaim. The consequence, called out as future work in store `D10`, is that store entries accumulate that **no toml references any more**. There is today no way to see them or reclaim their disk. This design adds a read-mostly management provider, **`/orphans`**, that surfaces exactly those unreferenced store entries and lets the user rename or delete them, or queue them for a listen before deciding. It is the store's garbage-collection UI, expressed as an ordinary library subtree so it needs no new client concepts. This **realizes** store `D10` (orphan reclamation). It changes nothing about how captures are written or de-duplicated; it only *reads* the store's residue and offers targeted rename/delete. ## Assumptions - **Confirmed by the request.** `/orphans` lists every store item, walks all local file providers, crosses off referenced items, and presents the rest; entries are renamable (audio file *and* sidecar), deletable (files on disk), and queueable. - **"Referenced" means reachable through a mounted local file provider.** The reference scan walks the disk roots of the running file providers — the `/crabidy` toml tree (`~/.local/state/crabidy/`, which holds `current`, every save, every capture) and the `/fs` root (which can hold `Playable::Store` tomls written by `scan --capture`/`--move`). A `.cbd-track.toml` that lives *outside* every mounted provider root (e.g. `scan --capture` run on a folder that is not under `/fs`) is invisible to the walk, so its target counts as an orphan. This is the only sound definition available without a global reference index, and it matches the request's wording ("walks all local file providers"). It is a documented boundary, not a bug (see Risks). - **Single writer.** As with the rest of the store, one process owns both roots and serializes mutations under the store's index mutex; there is no concurrent external writer. - **Orphan-ness is recomputed on every listing.** There is no persisted orphan list — consistent with `fsdy`'s "read the tree fresh every visit" philosophy. A capture that adds a reference makes an entry stop being an orphan on the next listing. ## What is a "store item" and when is it an orphan - A **store item** is a pair in `store_root`: an audio file `` and its sidecar `.cbd-store.toml`. The sidecar is the source of truth (store `D2`); the set of items is the set of sidecars that have a readable audio file beside them. (A sidecar without audio, or audio without a sidecar, is malformed residue — reported so it can be reclaimed too; see D4.) - A store item `` is **referenced** iff some `.cbd-track.toml` under a mounted file-provider root validates to `Playable::Store()`. - **Orphans = all store items − referenced store items.** Because `/orphans` only ever exposes unreferenced entries, renaming or deleting one **cannot break any toml reference** — that is what makes the destructive operations safe by construction (subject to the narrow race in Risks). ## Options considered ### Presentation: tracks vs. child nodes Each orphan must be renamable, deletable, and queueable. The wire has two carriers with capability flags: - **`Track`** — has `is_captured`/`is_skipped` but **no** `is_editable`. Tracks can be deleted (`tracks_deletable`) and queued, but the library has **no rename-a-track gesture** anywhere; adding one means new proto surface plus TUI and web changes. - **`LibraryNodeChild`** — already carries `is_editable`, `is_deletable`, `is_queable`, `is_captured`. The node-editing feature already binds `e` → `rename_lib_node(child_path, new_title)` and `d` → `delete_lib_node(child_path)` for children that advertise the flags (that is how `/tidal/search` terms and `/crabidy` saves are renamed/deleted today), and queueing a queueable child resolves its tracks. **Decision: present each orphan as an editable + deletable + queueable child node** of `/orphans`, titled by its store file `` (the thing a rename edits). Entering the node lists its single track (the store audio, with metadata from the sidecar); queueing the node — or the whole `/orphans` root — resolves that track. This reuses `rename_lib_node`/`delete_lib_node`/`resolve_tracks_into` and every client gesture **with zero proto, TUI, or web changes**. The only minor wart — each orphan is a one-track "folder" — is acceptable and is exactly how a single-track save already presents. The track-carrier option was rejected purely on cost: it buys nothing the node model lacks and forces a wire change just to gain a rename gesture. ### Home of the logic: new crate vs. server module The orphan computation needs the store root and index (to enumerate items and mutate them) *and* the file-provider disk roots (to find references). A standalone `orphandy` crate would have to duplicate `CrabidyStore` internals it does not own. **Decision: keep it in `crabidy-server`.** Orphan enumeration, rename, and delete become methods on `CrabidyStore` (it already owns `store_root` and the index). A thin new `OrphansProvider` (`crabidy-server/src/orphans.rs`) implements `ProviderClient`, holding `Arc` plus the list of reference roots to walk, and delegates to those store methods. `ProviderOrchestrator` mounts and routes `/orphans` exactly like the other providers. ## Boundaries and interfaces ```d2 direction: right tui: TUI / web / cbd-cli { shape: person } orchestrator: ProviderOrchestrator { routes: "routes /orphans/*" } orphans: OrphansProvider { refroots: "ref_roots: Vec" } store: CrabidyStore { index: "StoreIndex (by_hash / by_provider_id)" ops: "list_orphans / rename_orphan / delete_orphan" } data: content store\n~/.local/share/crabidy { shape: cylinder items: " + .cbd-store.toml" } crabidytree: /crabidy tree\n~/.local/state/crabidy { shape: cylinder } fstree: /fs root { shape: cylinder } tui -> orchestrator: "get / rename / delete / queue /orphans/*" orchestrator -> orphans: delegate orphans -> store: "enumerate + mutate (by name)" store -> data: "read sidecars, rename/delete files" orphans -> crabidytree: "walk for Playable::Store refs" orphans -> fstree: "walk for Playable::Store refs" ``` ### The orphan diff (what a listing computes) ```d2 direction: down allitems: "all store items\n(scan *.cbd-store.toml in store_root)" refs: "referenced set\n(walk ref_roots for\nPlayable::Store(name))" diff: "orphans = all − referenced" { shape: diamond } node: "/orphans node:\none editable/deletable/queueable\nchild per orphan" allitems -> diff refs -> diff diff -> node ``` ### Provider surface (`OrphansProvider: ProviderClient`) Mounted at `/orphans` only when `crabidy_store` is present (it is the store's view). Paths: the root `/orphans`, and one child per orphan at `/orphans/`. There are no deeper levels. - `get_lib_root` / `get_lib_node("/orphans")` — a queueable, non-creatable node whose children are the current orphans (recomputed by the diff above). Each child: `title = `, `is_queable = true`, `is_editable = true`, `is_deletable = true`, `is_downloadable = false`, `is_captured = true`. - `get_lib_node("/orphans/")` — a queueable, childless node carrying the single `Track` for that store entry (metadata from the sidecar's first provider entry; `is_captured = true`). Unknown/renamed-away segment → `MalformedPath`. - `is_track_path` — always `false`: orphans are addressed as nodes, and the one track is reached by resolving the node (so the default `resolve_tracks_into` walk queues it). `get_metadata_for_track` therefore is not the entry point; `get_urls_for_track("/orphans/")` returns `store_root/` (a local file path, exactly like a resolved `Playable::Store`) so the resolved track still plays. - `rename_lib_node("/orphans/", new)` — validates `new` as a bare store file name (`validate_folder_name(new, &[])`: non-empty, no separators/NUL, no leading dot), refuses a name already taken by another store entry (`InvalidInput`), then renames **both** ``→`` audio and `.cbd-store.toml`→`.cbd-store.toml`, and updates the in-memory index (drop the old name's mappings, re-insert under the new name; hash and provider ids are unchanged). Returns the renamed node at `/orphans/`. - `delete_lib_node("/orphans/")` — removes the audio file and the sidecar from `store_root` and drops the entry from the index; returns the refreshed `/orphans` root. Idempotent (an already-gone entry succeeds). - `create_lib_node` — `NotSupported` (the root is not creatable). ### Store methods added to `CrabidyStore` - `list_orphans(&self, ref_roots: &[PathBuf]) -> Result, StoreError>` — scan `store_root` for `*.cbd-store.toml`; build the referenced set by walking each `ref_root` recursively for `*.cbd-track.toml` and collecting `Playable::Store(name)`; return the difference as `OrphanEntry { name, title, artist, duration, album }` (metadata from the sidecar's first provider entry). - `orphan_track(&self, name) -> Result` / `orphan_url(&self, name) -> Result` — build the wire track / resolve the store audio path for a single entry. - `rename_orphan(&self, old, new)` / `delete_orphan(&self, name)` — the mutations above, under the index mutex, with the index kept in sync. - `StoreIndex::remove(&mut self, name, sidecar)` — the inverse of `insert`, so rename/delete can update the derived index without a full rescan. ### Reference roots wiring `OrphansProvider` is constructed in `ProviderOrchestrator::init` with `ref_roots` = the disk roots of the mounted file providers: the `/crabidy` tree (`store.tree_dir()`) and, when enabled, the `/fs` root. A new `fsdy::Client::disk_root(&self) -> &Path` accessor exposes the `/fs` root (the `/crabidy` tree root is already available via `CrabidyStore::tree_dir`). If more `fsdy` instances are ever mounted, they are added to this list — the definition of "local file provider" is "an `fsdy` instance whose root can hold store references." ## Risks and open questions - **Capture-then-delete race (TOCTOU).** Between a `/orphans` listing and a delete, a concurrent `W` capture could hash-hit the very entry the user is about to delete and write a fresh `Playable::Store` reference to it; deleting then leaves that new toml dangling. The window is small (store mutations serialize under the index mutex and orphan-ness is recomputed every listing), and the failure is benign: a dangling store reference already resolves to `MalformedPath` at play time and is skipped, not a crash. Accepted; noted here rather than engineered away. - **References outside mounted roots are not counted.** As stated in Assumptions, a `scan --capture` toml under a folder that is not mounted under `/fs` will not be seen, so its target shows as an orphan. Deleting it would orphan that toml's audio. The mitigation is scope discipline (scan under `/fs`); the alternative — a persisted global reference index — is out of scope and would fight the "read fresh" design. - **Cost.** A `/orphans` listing scans the whole store plus both trees on every visit (no cache), i.e. O(store entries + tomls under the roots). This matches `fsdy`'s existing per-visit read cost and is fine for personal-library sizes; if it ever bites, memoizing behind the index's mutation counter is the escape hatch. Not premature-optimized here. - **Malformed residue.** A sidecar with no audio (or vice versa) is itself reclaimable junk. `list_orphans` reports such half-entries as orphans (titled by whatever is present) so a delete cleans them up; it never treats a half-entry as "referenced." - **Open question:** should the `/orphans` root also expose a single "delete all" affordance? Deferred — per-item delete covers the request; bulk reclaim can be a later addition (a client could multi-select and delete, once marks exist there).