--- phase: 13-aiui-functional-conversational-node-control-and-content-surf plan: 04 type: execute wave: 2 depends_on: ["13-01"] files_modified: - core/archipelago/Cargo.toml - core/archipelago/src/music/mod.rs - core/archipelago/src/music/tags.rs - core/archipelago/src/main.rs - .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-MUSIC-MODEL.md autonomous: false requirements: [AIUI-03] must_haves: truths: - "The album/artist/track entity model and its on-disk index format are decided by the developer and written down before any node indexes a library (D-13, one-way)" - "`lofty` is added only after a human has confirmed its registry legitimacy — 13-RESEARCH.md marks it [ASSUMED] because the automated package-legitimacy seam was unavailable" - "Tag extraction returns a typed record for MP3, FLAC, M4A and OGG, and returns a filename-derived fallback record rather than an error for a file with no readable tags" - "Nothing in this plan reads or writes outside the node's own media roots" artifacts: - path: ".planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-MUSIC-MODEL.md" provides: "The recorded one-way decision: entity model, index location, index format, reindex path" - path: "core/archipelago/src/music/tags.rs" provides: "lofty-based extraction of title/artist/album/albumartist/track/disc/year/duration" contains: "pub fn extract_tags" - path: "core/archipelago/src/music/mod.rs" provides: "Music domain root: Track, Album, Artist entity types as decided" contains: "pub struct Track" key_links: - from: "core/archipelago/src/main.rs" to: "core/archipelago/src/music/mod.rs" via: "mod music; declaration — the crate is binary-only, there is no lib.rs" pattern: "^mod music;" --- Land the **one-way** half of D-13 deliberately. CONTEXT.md rates the music library's album/artist/track schema and its on-disk index as **one-way**: "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." So the entity model is decided at a checkpoint by the developer, written down, and only then implemented. This plan also clears the two gates that stand in front of any music code: the recorded decision (REVERSIBILITY_GATES) and `lofty`'s package legitimacy (13-RESEARCH.md marks it `[ASSUMED]` because the automated `package-legitimacy check` seam was unavailable in the research session, and its own fallback rule says an `[ASSUMED]` package's `cargo add` must be gated behind a `checkpoint:human-verify`). **Wave note (D-13 independence).** D-13 requires the music library to land as its own wave "not blocking the rest" — and it does: **no plan on the control or content track depends on any plan in the music track.** The edge here points the other way, and it is a file-serialization fact rather than a logical coupling: `core/archipelago/src/main.rs` is the binary crate's only module-declaration site, so `mod assistant;` (13-01) and `mod music;` (this plan) cannot be written in the same wave. Nothing in this plan uses anything 13-01 produces. Purpose: get the irreversible decision made while it is still cheap, and get the dependency audited before it is in the tree. Output: `13-MUSIC-MODEL.md`, `lofty` in `Cargo.toml`, and `core/archipelago/src/music/`. None in this plan. Symbols created by **this plan**: - `core/archipelago/src/music/mod.rs`: `pub struct Track`, `pub struct Album`, `pub struct Artist`, `pub struct TrackId`/`AlbumId`/`ArtistId` (or the identity scheme chosen at Task 1), `pub enum MusicSource`, `const MUSIC_SCHEMA_VERSION` - `core/archipelago/src/music/tags.rs`: `pub fn extract_tags`, `pub struct RawTags`, `fn fallback_from_filename` - `core/archipelago/src/main.rs`: `mod music;` - `core/archipelago/Cargo.toml`: `lofty` dependency - New file `.planning/phases/13-.../13-MUSIC-MODEL.md` @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/STATE.md @CLAUDE.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-RESEARCH.md Task 1: Decide the music entity model — one-way The album / artist / track entity model, the identity scheme that survives a file move or a retag, where the index lives on disk, and what a reindex path looks like when the schema changes. Also, within CONTEXT.md's "Claude's Discretion": whether the library indexes the node's own FileBrowser `Music` folder, peer audio, or both. D-13 rates this **one-way**: once nodes have indexed libraries, changing the entity model needs a reindex path, not just a code change. The three sub-decisions that are genuinely hard to walk back are (a) what a *track's stable identity* is, (b) whether an album is a first-class stored entity or derived at read time, and (c) the on-disk index format. Grounding for the developer: - There is **no music library domain in this codebase today** — CONTEXT.md is explicit that the user chose "build a real library" over the narrower MIME-filtered-files option after being told this. There is nothing to migrate *from*, which is exactly why now is the cheap moment. - `content_server.rs::load_catalog` is the in-repo precedent for a `data_dir`-scoped catalog that is scanned and persisted; `13-PATTERNS.md` assigns it as the analog for `music/index.rs`. - A relevant landmine: `ShareModal.vue`'s mime map omits `m4a`/`aac`/`opus`/`wma`, so those files today share as `application/octet-stream`, never reach the audio player, and are auto-filed to `Documents` instead of `Music`. Whatever "the Music folder" means to the index must survive that (13-11 fixes the mime map). - `.planning/phases/13-.../13-MUSIC-MODEL.md` exists and states, each in one paragraph: the track identity scheme; whether albums and artists are stored or derived; the on-disk index path under `data_dir` and its format; the sources indexed (own `Music` folder, peer audio, or both); and the reindex path when `MUSIC_SCHEMA_VERSION` bumps - The file names a `MUSIC_SCHEMA_VERSION` starting value and states what a node does on encountering an index written by a *newer* version - The file explicitly records which option ids above were chosen and one sentence on why the rejected ones were rejected Select one identity option, one album option and one index-format option (e.g. "hybrid-identity, derived-albums, index-format-json"), or describe a different model. Task 2: Verify lofty's registry legitimacy before it enters the tree Nothing yet — this gate runs **before** `cargo add`. `13-RESEARCH.md`'s Package Legitimacy Audit marks `lofty` `[ASSUMED]`: the automated `gsd-tools query package-legitimacy check` seam was unavailable in the research session, so legitimacy was assessed by manual crates.io inspection only (808,246 downloads, repo `github.com/Serial-ATA/lofty-rs`, active). The audit's own fallback rule requires an `[ASSUMED]` package's install to be gated behind a human check. This is that check. It is not auto-approvable regardless of `workflow.auto_advance`. 1. Open `https://crates.io/crates/lofty` and confirm: the crate has a substantial download history (not a recent spike), a listed repository, and a version history spanning more than a few weeks. 2. Open the linked repository `https://github.com/Serial-ATA/lofty-rs` and confirm it is a real project with commit history and issues, and that the repo link on crates.io points at it (not at an unrelated or newly-created org). 3. Confirm the version being added matches what RESEARCH observed: `0.24.x`. 4. Sanity-check the dependency tree before committing to it: `cd core && cargo add --dry-run lofty --package archipelago` and read what it would pull in. A tag-reading crate pulling in a network or process-spawning dependency is a red flag. - The developer states the observed download count, the repo URL and the version - `cd core && cargo add lofty --package archipelago` has been run and `grep -c '^lofty' core/archipelago/Cargo.toml` returns 1 - `cd core && CARGO_INCREMENTAL=0 cargo build --package archipelago` exits 0 - `cd core && cargo tree --package archipelago -i lofty` output is reviewed and contains no networking crate Type "approved" with the download count and repo URL you saw, or "rejected" with what looked wrong. Task 3: Tag extraction across the four formats that actually matter core/archipelago/src/music/mod.rs, core/archipelago/src/music/tags.rs, core/archipelago/src/main.rs - An MP3 with ID3v2.4 tags yields title, artist, album, album artist, track number, disc number, year and duration. - A FLAC with Vorbis comments yields the same fields. - An M4A/AAC file yields the same fields (this is the format `ShareModal.vue` currently mis-types — it must not be second-class here). - An OGG file yields the same fields. - A file with **no** readable tags yields a record whose title is derived from the filename stem and whose artist/album are `None` — an error is not returned, because an untagged file must still appear in the library. - A file that is not audio at all (a `.txt` renamed to `.mp3`) returns `Err`, and the caller can distinguish it from the untagged case. - A path outside the configured media roots is refused before any file is opened. - `.planning/phases/13-.../13-MUSIC-MODEL.md` — Task 1's decision. **The entity types in `mod.rs` are written to match it exactly; do not re-derive a model here.** - `core/archipelago/src/content_server.rs` — `ContentItem`, `AccessControl` and `load_catalog`. `13-PATTERNS.md` assigns this as the role-match analog for the music domain's load/scan/persist shape. Read `load_catalog` in full. - `core/archipelago/src/content_hash.rs` — the in-tree hashing primitive, if Task 1 chose a content-hash or hybrid identity. - `core/archipelago/src/swarm/payment.rs` — the `#[cfg(test)]`/`#[tokio::test]` convention and `tempfile` usage for fixture directories. - `lofty` docs for the 0.24 API surface: prefer `lofty::read_from_path` plus the `TaggedFileExt`/`Accessor`/`AudioFile` traits over per-format parsers. Create `core/archipelago/src/music/mod.rs` and `core/archipelago/src/music/tags.rs`, and add `mod music;` to `core/archipelago/src/main.rs` in the existing alphabetical block (between `mod monitoring;` and `mod names;`). This crate is binary-only — there is no `lib.rs` — so all tests are in-crate `#[cfg(test)] mod tests`. `mod.rs` declares the entity types exactly as decided in `13-MUSIC-MODEL.md`: `Track`, `Album`, `Artist` (stored or derived per the decision), the identity newtypes, `pub enum MusicSource { OwnLibrary, Peer { onion: String } }` restricted to whatever Task 1 chose to index, and `pub const MUSIC_SCHEMA_VERSION: u32` at the decided starting value. Every struct derives `Serialize`/`Deserialize` — the index is persisted, so these types are the migration surface and must be written once, carefully. `tags.rs` exposes `pub fn extract_tags(path: &Path, media_roots: &[PathBuf]) -> Result`. It first canonicalizes `path` and refuses with a distinct error if the result is not under one of `media_roots` — an indexer that can be pointed at `data_dir/secrets` is a secret-exfiltration primitive, and this check runs before the file is opened, not after. It then uses `lofty::read_from_path` and the `Accessor` trait to pull title, artist, album, album artist, track, disc, year, and `AudioFile::properties().duration()`. `RawTags` carries `Option`/`Option` fields plus a `has_tags: bool`. On a readable audio file with no tag block, populate `title` from the file stem via `fallback_from_filename` and set `has_tags: false`. On a file `lofty` cannot identify as audio, return `Err` with a variant the caller can distinguish from the untagged case. Write the tests FIRST, one per bullet in ``. Generate the fixture files programmatically into a `tempfile::tempdir()` using `lofty`'s own writing API where it supports the format, rather than committing binary fixtures — a repo full of committed sample audio is a licensing problem and a review burden. For the not-audio case, write a text file with an `.mp3` extension. For the path-traversal case, point at a temp path outside the roots. Name them `music::tags::tests::mp3_id3v24_yields_full_record`, `..::flac_vorbis_yields_full_record`, `..::m4a_yields_full_record`, `..::ogg_yields_full_record`, `..::untagged_file_falls_back_to_filename_stem`, `..::non_audio_returns_err_distinct_from_untagged`, `..::path_outside_media_roots_is_refused`. cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago music:: 2>&1 | tail -20 grep -q '^mod music;' core/archipelago/src/main.rs - `grep -q '^mod music;' core/archipelago/src/main.rs` - `grep -q 'pub struct Track' core/archipelago/src/music/mod.rs` and `grep -q 'MUSIC_SCHEMA_VERSION' core/archipelago/src/music/mod.rs` - `grep -q 'pub fn extract_tags' core/archipelago/src/music/tags.rs` - `cd core && cargo test --package archipelago music::` exits 0 with all seven named tests passing - `grep -q 'media_roots' core/archipelago/src/music/tags.rs` — the root confinement is a parameter, not a constant a caller can bypass - `git ls-files core/archipelago | grep -ciE '\.(mp3|flac|m4a|ogg)$'` returns 0 — no binary audio fixtures were committed - The entity fields in `mod.rs` match `13-MUSIC-MODEL.md`'s decision (spot-check each name) The entity model and index format become a persisted data model once nodes index libraries; changing them afterwards needs a reindex path, not just a code change. Gated by Task 1's `checkpoint:decision`, per CONTEXT.md D-13's own rating. Four real audio formats round-trip into a typed record, an untagged file still becomes a library entry, a non-audio file is a distinguishable error, and a path outside the media roots never gets opened. ## Trust Boundaries | Boundary | Description | |----------|-------------| | filesystem → indexer | Media files are attacker-influenceable (a peer chooses the filename and the tag contents of anything shared) | | tag text → downstream context | Tag strings are peer-supplied text and will eventually reach the model context and the UI — D-10 territory | | crates.io → the tree | A new third-party parser handling untrusted binary input | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-13-20 | Information Disclosure | Indexer pointed at `data_dir/secrets` or another sensitive path | high | mitigate | `extract_tags` canonicalizes and confines to `media_roots` **before opening the file**; asserted by `path_outside_media_roots_is_refused`. The roots are a parameter, not a constant | | T-13-21 | Denial of Service | Malformed/hostile audio file crashes or hangs the parser | medium | mitigate | `lofty` errors are returned as `Err`, never `unwrap`ped; a non-audio file is a normal error path, asserted by `non_audio_returns_err_distinct_from_untagged`. No panic path is introduced | | T-13-22 | Tampering | Peer-authored tag text treated as trusted once it is "structured data" | high | mitigate | Deferred by design to 13-12's `wrap_untrusted` boundary: `RawTags` fields are plain `Option` carrying no trust, and nothing in this plan puts them in a model context. Recorded here so the assumption is explicit rather than implied | | T-13-23 | Elevation of Privilege | Music entity model later needs a field that only exists on a stored album, forcing an on-disk migration | medium | mitigate | This is the one-way cost D-13 names. Mitigated by making it a `checkpoint:decision` and by `MUSIC_SCHEMA_VERSION` + a written reindex path, not by trying to guess right | | T-13-SC | Tampering | npm/pip/cargo installs | **high** | mitigate | `lofty` is `[ASSUMED]` in 13-RESEARCH.md's Package Legitimacy Audit. Task 2 is a `checkpoint:human-verify` with `gate="blocking-human"` **before** `cargo add`, per the audit's own fallback rule. Not auto-approvable. `cargo tree -i lofty` is reviewed for unexpected transitive networking deps | - `cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago music::` green (7 tests) - `cd core && CARGO_INCREMENTAL=0 cargo build --package archipelago` exits 0 - `13-MUSIC-MODEL.md` exists and its decided field names match `music/mod.rs` - `grep -c '^lofty' core/archipelago/Cargo.toml` == 1 The irreversible half of D-13 is a written, developer-made decision rather than an emergent property of the first implementation; `lofty` entered the tree through a human legitimacy gate; and tag extraction handles the four formats a real library contains, including the M4A/AAC family the current share path mis-handles. Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-04-SUMMARY.md` when done