Files
archy/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-04-PLAN.md
T

20 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
13-aiui-functional-conversational-node-control-and-content-surf 04 execute 2
13-01
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
false
AIUI-03
truths artifacts key_links
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
path provides
.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-MUSIC-MODEL.md The recorded one-way decision: entity model, index location, index format, reindex path
path provides contains
core/archipelago/src/music/tags.rs lofty-based extraction of title/artist/album/albumartist/track/disc/year/duration pub fn extract_tags
path provides contains
core/archipelago/src/music/mod.rs Music domain root: Track, Album, Artist entity types as decided pub struct Track
from to via pattern
core/archipelago/src/main.rs core/archipelago/src/music/mod.rs mod music; declaration — the crate is binary-only, there is no lib.rs ^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/.

<flagged_assumptions> None in this plan. </flagged_assumptions>

<artifacts_this_phase_produces> 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 </artifacts_this_phase_produces>

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.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). Track identity = content hash of the audio payload Survives renames, moves and retags. The same track shared by two peers deduplicates naturally. `content_hash.rs` already exists in-tree. Requires reading every byte of every file at index time — expensive on a large library on modest node hardware. A re-encode produces a different identity for the same recording. Track identity = (source, canonical path) Cheap — stat-only indexing, fast reindex, trivially incremental via mtime. A move or a rename orphans the row and any play counts or favourites attached to it. Two peers sharing the same album are two libraries, never one. Path is the row key; content hash is a lazily-computed dedupe column Fast first index, dedupe available when it is worth paying for, and the expensive column can be back-filled without a schema change. Two identity notions to keep straight; dedupe correctness depends on a back-fill that may lag. Albums/artists derived at read time from track tags (vs. stored as first-class rows) No album-identity problem at all; a retag just changes what the grouping produces. Least to migrate later. No place to hang album-level data (cover art path, review, purchase record) later without a schema change — which is the one-way cost this decision is about. Index format: a single JSON file under data_dir, like content_server.rs's catalog Matches the in-repo precedent exactly; human-inspectable; trivial backup/restore; no new dependency. Whole-file rewrite per update; poor above a few thousand tracks. Index format: SQLite under data_dir Incremental writes, real queries, scales past a large personal library. A new dependency that is NOT in 13-RESEARCH.md's Package Legitimacy Audit — adopting it requires its own audit and human-verify gate, which this phase has not budgeted.
  • .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 </acceptance_criteria> 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<RawTags>. 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<String>/Option<u32> 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 <behavior>. 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 <acceptance_criteria>

  • 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) </acceptance_criteria> 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.

<threat_model>

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 unwrapped; 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<String> 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
</threat_model>
- `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

<success_criteria> 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. </success_criteria>

Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-04-SUMMARY.md` when done