docs(13-04): record D-13 one-way music entity model decision

Task 1 checkpoint:decision resolved by operator: hybrid-identity (path
row key, lazily-backfilled content-hash dedupe column), derived-albums
(computed at read time from track tags, not stored rows), a single
JSON index at data_dir/music/index.json matching content_server.rs's
load_catalog precedent, and both own-library + peer sources indexed.
MUSIC_SCHEMA_VERSION starts at 1; a newer-version index on an older
binary is treated as absent rather than reinterpreted or overwritten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-04 05:08:29 -04:00
co-authored by Claude Fable 5
parent bf9b4e6ad3
commit bca4660a95
@@ -0,0 +1,89 @@
# 13-04 Task 1: Music Entity Model — One-Way Decision (D-13)
**Decided:** 2026-08-04, by the operator, at the `checkpoint:decision` gate in `13-04-PLAN.md`
Task 1. This is the one-way half of D-13: the album/artist/track entity model and its on-disk
index format, decided once, before any node indexes a library, per CONTEXT.md's own rating
("a persisted data model with a migration cost once nodes have indexed libraries; changing the
entity model afterwards needs a reindex path, not just a code change").
## Track identity: hybrid-identity
A track's stable row key is `(source, canonical path)` — cheap, stat-only indexing, trivially
incremental via mtime, matching `content_server.rs::load_catalog`'s existing scan/persist shape.
A content hash (reusing `content_hash.rs`'s `sha256_hex`/`blake3_hex`) is a **lazily-computed
dedupe column** on the same row: populated by a background backfill pass, not required at
first-index time. This gives a fast first index on modest node hardware while leaving a path to
real dedupe (the same track shared by two peers, or present twice on one node) once the hash
column catches up.
**Rejected:** `content-hash-identity` (track identity = content hash of the audio payload) —
requires reading every byte of every file at index time, which is too expensive on a large
library on modest node hardware, and a re-encode of the same recording would produce a different
identity, defeating the purpose. `path-identity` (track identity = `(source, canonical path)`
alone, no hash column ever) — a file move or rename orphans the row and any play counts or
favourites attached to it, and two peers sharing the same album stay two separate libraries
forever with no dedup path.
## Albums/artists: derived-albums
Albums and artists are **not** stored as first-class rows. They are computed at read time by
grouping the track index on `(album, album_artist)` / `artist` tag fields. There is no
album-identity or album-merge problem to solve, and a retag just changes what the grouping
produces on the next read — nothing to migrate.
**Rejected:** storing albums/artists as first-class rows — this creates an album-identity and
merge problem (what makes two rows "the same album"?) and adds migration surface for a benefit
(a place to hang album-level extras like cover art path, a review, a purchase record) that isn't
needed yet and can be added later via a schema bump + reindex, which is exactly the reindex path
this document defines below.
## Index format: index-format-json
The index is a single JSON file under `data_dir`, at `data_dir/music/index.json`, matching
`content_server.rs::load_catalog`'s `data_dir.join(CATALOG_FILE)` precedent exactly (JSON file
under a subdirectory, loaded via `serde_json::from_str`, defaulted on missing/corrupt, saved via
`serde_json::to_string_pretty` + `fs::write`). Human-inspectable, trivial to back up, and adds no
new dependency.
**Rejected:** `index-format-sqlite` — a new dependency that fell outside this plan's Package
Legitimacy Audit (13-RESEARCH.md's audit covers `lofty` only), which would need its own
legitimacy-review gate that this phase has not budgeted. Whole-file JSON rewrite cost is accepted
as a tradeoff for a personal-library-scale index; if a library later grows past what whole-file
rewrites can serve comfortably, that's a `MUSIC_SCHEMA_VERSION` bump away, not a blocker today.
## Sources indexed: both
The music library indexes **both** the node's own FileBrowser `Music` folder (`MusicSource::OwnLibrary`)
and peer-shared audio reachable through the existing content/peer-proxy subsystem
(`MusicSource::Peer { onion: String }`). This matches 13-11's plan to surface peer content in
`SongGrid` alongside the node's own library, and reuses `content.*`'s existing peer-audio
discovery rather than building a second one. Indexing "the Music folder" must survive the
`ShareModal.vue` mime-map landmine noted in `13-CONTEXT.md` (m4a/aac/opus/wma currently
mis-share as `application/octet-stream` and get auto-filed to `Documents`) — 13-11 fixes the
mime map; this index's own tag-extraction path (13-04 Task 3) does not depend on that fix, since
it reads file contents directly, not the share-time MIME guess.
## Schema version and the reindex path
`MUSIC_SCHEMA_VERSION` starts at **`1`**. It is written into `data_dir/music/index.json` as a
top-level field alongside the track/album data.
**On load, if the on-disk index's `MUSIC_SCHEMA_VERSION` is older than the running binary's
constant:** the node runs a migration step for each version delta before use (a no-op for
version 1, since there is no prior version). This is the routine, expected case as the schema
evolves.
**On load, if the on-disk index's `MUSIC_SCHEMA_VERSION` is *newer* than the running binary's
constant** (an older binary encountering an index written by a newer version — e.g. after a
downgrade, or a shared `data_dir` touched by a newer node): the node does **not** attempt to
read or reinterpret the newer-format data. It logs a warning naming both versions, treats the
index as absent (starts from an empty in-memory index), and does **not** overwrite the on-disk
file until a reindex is explicitly triggered — this avoids a downgraded node silently truncating
or corrupting an index a newer node will read again later. A full reindex (rescan `OwnLibrary`
and re-request peer catalogs) is the standard recovery path whenever `MUSIC_SCHEMA_VERSION`
changes in either direction; because albums/artists are derived rather than stored, a reindex
only needs to rebuild the track rows, not reconcile any stored album/artist state.
## Summary of chosen option ids
`hybrid-identity`, `derived-albums`, `index-format-json`, sources = both own-library and peer.