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

275 lines
20 KiB
Markdown
Raw Normal View History

2026-08-12 10:55:49 +00:00
---
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;"
---
<objective>
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/`.
</objective>
<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>
<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
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking">
<name>Task 1: Decide the music entity model — one-way</name>
<decision>
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.
</decision>
<context>
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).
</context>
<options>
<option id="content-hash-identity">
<name>Track identity = content hash of the audio payload</name>
<pros>Survives renames, moves and retags. The same track shared by two peers deduplicates naturally. `content_hash.rs` already exists in-tree.</pros>
<cons>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.</cons>
</option>
<option id="path-identity">
<name>Track identity = (source, canonical path)</name>
<pros>Cheap — stat-only indexing, fast reindex, trivially incremental via mtime.</pros>
<cons>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.</cons>
</option>
<option id="hybrid-identity">
<name>Path is the row key; content hash is a lazily-computed dedupe column</name>
<pros>Fast first index, dedupe available when it is worth paying for, and the expensive column can be back-filled without a schema change.</pros>
<cons>Two identity notions to keep straight; dedupe correctness depends on a back-fill that may lag.</cons>
</option>
<option id="derived-albums">
<name>Albums/artists derived at read time from track tags (vs. stored as first-class rows)</name>
<pros>No album-identity problem at all; a retag just changes what the grouping produces. Least to migrate later.</pros>
<cons>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.</cons>
</option>
<option id="index-format-json">
<name>Index format: a single JSON file under data_dir, like content_server.rs's catalog</name>
<pros>Matches the in-repo precedent exactly; human-inspectable; trivial backup/restore; no new dependency.</pros>
<cons>Whole-file rewrite per update; poor above a few thousand tracks.</cons>
</option>
<option id="index-format-sqlite">
<name>Index format: SQLite under data_dir</name>
<pros>Incremental writes, real queries, scales past a large personal library.</pros>
<cons>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.</cons>
</option>
</options>
<acceptance_criteria>
- `.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>
<resume-signal>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.</resume-signal>
</task>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 2: Verify lofty's registry legitimacy before it enters the tree</name>
<what-built>
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`.
</what-built>
<how-to-verify>
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.
</how-to-verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<resume-signal>Type "approved" with the download count and repo URL you saw, or "rejected" with what looked wrong.</resume-signal>
</task>
<task type="auto" tdd="true">
<name>Task 3: Tag extraction across the four formats that actually matter</name>
<files>core/archipelago/src/music/mod.rs, core/archipelago/src/music/tags.rs, core/archipelago/src/main.rs</files>
<behavior>
- 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.
</behavior>
<read_first>
- `.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.
</read_first>
<action>
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`.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test --package archipelago music:: 2>&amp;1 | tail -20</automated>
<automated>grep -q '^mod music;' core/archipelago/src/main.rs</automated>
</verify>
<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>
<reversibility rating="one-way">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.</reversibility>
<done>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.</done>
</task>
</tasks>
<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 `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<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>
<verification>
- `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
</verification>
<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>
<output>
Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-04-SUMMARY.md` when done
</output>