# PearTune Decisions Append-only, newest on top. See Constitution §4. ## 2026-09-17 - The host runs on @peerloom/host Tier: T3 (the firewall, grant store, pairing and revoke path change implementation). Proposal `../proposals/2026-08-12-shared-host.md`, option C, approved 2026-08-12; plan and drift audit in `../proposals/2026-09-17-peartune-host-migration-plan.md`. The iOS 1.0.0 outcome option C waited on is known and 1.0.7-1.0.9 shipped since. Wire bytes and on-disk data do not change (test/host-package-wire.test.js). The audit found the package missing three PearTune fixes that each wrote wider folder access into the grant store; they went into the package first (peerloom-host #17), so PearCinema gained them too. host/server.js keeps sources, settings, speakers, voice, requests and now-playing; host/media.js keeps the method table; host/ui/auth.js keeps the cookie name and the PEARTUNE_PASSWORD text. Accepted changes: a phone that removes the library also stops its speaker cast (Tim, 2026-09-17), grant:changed pushes drop libraryId, device rows gain `confirmed`. The image installs in /build/app because npm ci refuses the lock's ../../peerloom-host from /app, and it copies the package so the image holds one hypercore stack. peerloom-host was made public (Tim, 2026-09-17) so source installs can clone it. Not merged until the hardware revoke test passes against the real Umbrel data. ## 2026-08-31 - cancel(6) on the media channel: the requester's way of hanging up Tier: T2 (a new wire message, strictly appended - old peers drop it and stream to completion, today's behaviour in both mixed directions). Proposal `proposals/2026-08-31-stream-cancel.md`, approved and shipped the same day. Every scrub and skip used to leave the host reading, transcoding and sending up to a full 2 MB window nobody would hear; the shim's hangup now cancels the in-flight window, the host destroys the SOURCE (a file read closes, a transcoding ffmpeg EPIPEs and frees its slot at the scrub), and no end or err is ever sent for a cancelled id. Client-side `.cancel()` RESOLVES with a marker rather than rejecting, because the shim's mid-song failover keys on stream failure and a player hanging up is the opposite of a host dying. A bounded pre-cancel set (128) catches a cancel racing the open. Deliberately NOT ported: PearCinema's time-governed player buffer - that knob is expo-video's, expo-audio has none, and audio does not need it. ## 2026-08-31 - The requester closes the ask: request.resolve admits the row's requester Tier: T3 (an auth gate moves). Proposal `proposals/2026-08-31-the-requester-closes-the-ask.md`, approved and shipped the same day; review note in reviews/. A request fans out to every reachable host by design, but resolving only ever wrote the one host it was called on, so two owners on two machines - the case the app is built for - each kept seeing an answered ask as pending. The fix is Tim's shape from the PearCinema round, adopted rather than re-asked: the REQUESTING DEVICE coordinates, because it is the only party that knows every copy exists (it holds them as refs); hosts never learn of each other. The gate becomes "the owner, or the row's own requester - and the requester only to mark a still-pending copy added": strictly less power than request.delete already grants (a requester can make their ask vanish), a decline never travels (another owner may still want to add it), and a requester can never flip an owner's decline. Healing, not firing once: the check runs on every merged request list, so a device asleep when the answer came settles it on next open, and an old host that refuses with owner-only degrades to today's bug rather than an error. ## 2026-08-31 - Per-person folders: grant.paths filled, fail-closed, folder source only Tier: T3 (a new rule on the authorisation surface). Proposal `proposals/2026-08-31-per-person-folders.md`, approved and shipped the same day (PR #405, review note in reviews/). The reserved `paths` null on every grant becomes a list of `{ root, rel }` prefixes; one module (`host/visibility.js`) answers visibility and the adapter is read through a per-dispatch view off the live grant snapshot, so a narrowing lands on open connections with no reconnect. The decisions that would be easy to unmake by accident: - **Fail closed, twice.** An item the adapter cannot place is hidden from a narrowed grant, and a SOURCE that cannot enforce a narrowing at all (the Subsonic proxy: no paths, upstream cursors, raw art ids) serves a narrowed grant nothing rather than everything. The dashboard refuses to create that state; the empty view is the backstop for a source swapped under an existing narrowing. Jellyfin (which reports real per-item paths) is the designed-in follow-up, not silently half-supported today. - **Music is derived entities.** An album/artist/genre is visible when at least one of its tracks is, and its wire row carries recomputed honest counts. The folder adapter answers this as `Object.create(adapter)` over filtered in-memory pools - every existing method filters itself - with the art gate ahead of the shared cover cache, which answers first and would otherwise serve a hidden album's warmed art. - **assign() inherits the person's narrowing.** The value lives per grant, so a device joining a narrowed person copies a sibling's paths - an assignment that silently widens is the hole, not the inconvenience. - **Pairing windows carry paths and the panel asks**, neither answer preselected, no code until answered; owner windows drop paths (the owner is never filtered). Both are Tim's calls from the PearCinema round, adopted rather than re-asked. - **Downloads already on a phone keep playing** after a narrowing, said out loud on the People page when saving - the host cannot reach an offline phone, and a rule enforced "sometimes" reads as a bug. Narrowing is not revoke; revoke is unchanged (pinned by an integration test). ## 2026-08-31 - A revoked device is told so once; confirmation is recorded; grant edits reach live connections Tier: T3 (the goodbye changes what the auth gate says to a refused peer); the other two parts T2. Proposal `proposals/2026-08-31-grant-fixes-trio.md`, approved by Tim the same day. All three ported by hand from `../peerloom-host` (#10/#12/#13), where PearCinema shipped and hardware-verified them; PearTune does not consume the shared package, so they had not flowed back. The security reasoning that unlocks the goodbye: never explaining a refusal is right for a stranger and wrong for a device holding a tombstoned grant - it already proved possession of a key this host granted, so "not any more" leaks nothing. `no-grant` stays silent. The farewell channel is a separate function with no method table in scope (never a flag on serveMedia), rate-limited to one goodbye per key per minute in a capped book. Revoke's guarantee is unchanged: nothing NEW after the cut; the acceptance test changes shape from "the socket does not open" to "one socket opens, is told why, every method is refused, the next knock is refused outright". `confirmedUser` records the claim the operator agreed to, so renamePerson stops rewriting `claimedUser` (the field the device itself sets) and backfills the recorded answer while the old name still exists to compare against. Rows without the field fall back to the old name comparison - no migration. `settleClaim` is the missing dashboard answer for a device that renamed itself while assigned: keep it with its person, granting nothing. `serveMedia` returns a handle with `setGrant`; `host.assignDevice` swaps the snapshot on every live connection and pushes `grant:changed`, so a reassignment is true now rather than at the next reconnect. The phone persists a heard goodbye to disk (`revoked.json`) - an in-memory verdict forgets on an airplane-mode relaunch - and clears it on the next dial that lands, which is also what makes re-pair need no special case. > Entries below sometimes say a loose end is "logged in TODO.md". **`TODO.md` and `DONE.md` > are local working notes and are not in this repository** - they are gitignored. Open work > that matters to anyone outside belongs in the issue tracker; those two files are the > maintainer's own scratch, and the pointers are kept as written rather than rewritten after > the fact, because this file is append-only. ## 2026-08-18 - The macOS updater now ends the old daemon, because swapping the .app never did Tier: T1 (update path; no wire or storage change). Follows directly from the incident recorded below, which was inferred to affect "UPDATE NOW" as well. It does, and this is the measurement. THE CAUSE WAS A COMMENT THAT STOPPED BEING TRUE. host/update-apply.js's macapp applier said: "macOS has no supervisor - it is a login-item tray app, measured - so it always relaunches itself." That WAS measured, and it was correct when written. It stopped being correct on 2026-08-08, when the system LaunchDaemon shipped. Nobody re-read it, so detectSupervisor kept its linux and win32 branches and never grew a darwin one - it returns null on macOS, and the applier swapped the .app and relaunched only the tray. So a Mac user accepting an update got: the new bundle installed, the UI reporting the new version, and the OLD host still serving the library from the replaced bundle by inode - until they rebooted. Silent, and indefinite. That is the third stale-comment failure in two days; the pattern is now unmistakable enough to say plainly: a comment asserting a measured fact needs a date and a re-read, because the thing it measured can change without touching the file. THE FIX IS A PLAIN KILL, NOT `launchctl kickstart`. kickstart on a system-domain job needs root and the updater runs as the user. But the daemon's plist sets UserName to the console user (deliberately, since 2026-08-08, so it does not leave root-owned files in the user's store), which means the same user may signal it. Measured on the mac-mini: pid 1384 before, `pkill -f` exited 0 as the user with no password, pid 2943 twelve seconds later, dashboard back at http 200, and the host still transcoding (12,174,566 -> 3,529,005 bytes on the same request). ORDER MATTERS AND IS PINNED BY A TEST: kill AFTER the swap. Kill first and KeepAlive restarts the host from the bundle that is still the old one, which is precisely the incident below. The match is on the daemon's host-entry argv rather than the process name, verified on the real machine: `pgrep -f "PearTune.app/Contents/Resources/app.asar/vendor/host"` matches pid 1384 and NOT the tray at 828, which carries no such argument. Killing by name would take the tray down too. FAILURE IS REPORTED, NOT PAPERED OVER. An install predating the run-as-user fix is root-owned and refuses the signal; the applier then returns staleDaemon and the dashboard says the update is installed but the background service is still on the old version, naming the one command that fixes it. Claiming success there would recreate the exact bug in a new place. Linux and Windows were already correct and were checked rather than assumed: the appimage applier restarts the systemd user unit (with --no-block, for the cgroup-teardown reason in its own comment), and the .deb helper restarts the unit itself, last. macOS was the only gap. ## 2026-08-18 - A binary inside app.asar exists but cannot run, and a KeepAlive daemon outlives its own install Tier: T1. Two findings from verifying the entry below ON HARDWARE, both of which make a green build and a correct-looking install serve the old broken behaviour. Neither was reachable from the unit tests; both were found by measuring the running system. FINDING 1 - THE FIX SHIPPED THE BUG INSIDE ITSELF. desktop/package.json's `files` glob included `vendor/**/*`, so electron-builder packed vendor/ffmpeg INTO app.asar as well as staging a real copy beside it via extraResources. Electron's fs shim reports a path inside an asar as EXISTING, so both candidates in ffmpeg-bin.js said EXISTS, the in-asar one is first and won - and nothing can spawn a file that lives inside an archive. hasFfmpeg() failed, transcode.js degraded to raw bytes, and the result was byte-for-byte the bug the whole change exists to fix. Every unit test passed, because they inject a mocked `exists` that only ever made the staged path exist. The real filesystem made BOTH true. That is the shape of the lesson: a mock that models only the case you intended cannot fail the way reality does. The regression test now makes both paths exist, exactly as the installed .app did. Fixed on both sides: `!vendor/ffmpeg/**` keeps it out of the archive (which also stopped shipping the binaries twice - the arm64 .dmg went 177MB -> 170MB), and resolveFfmpeg now refuses any candidate under an `.asar` whatever existsSync claims. `.asar.unpacked` stays spawnable, since that IS a real directory. FINDING 2 - AND THIS ONE IS BIGGER THAN THIS CHANGE. macOS installs a SYSTEM LaunchDaemon with KeepAlive true (/Library/LaunchDaemons/com.peerloom.peartune.plist, since 2026-08-08). Quit the app to replace it and launchd restarts the host IMMEDIATELY from the bundle still on disk. Measured: the daemon came up at 10:01:09 and /Applications/PearTune.app was replaced at 10:01:12 - three seconds later. The new bundle then sat under a process still executing the old code, indefinitely. How it presented is worth remembering, because it looked exactly like a broken fix. The installed app had ffmpeg staged, resolved it correctly, and transcoded a .wma to mp3 when invoked directly - while the process actually serving the library did none of those things. Every check on the APP passed. The SERVER was old. `sudo launchctl kickstart -k system/com.peerloom.peartune` fixed it instantly. THE CONSEQUENCE TO CHASE: the "UPDATE NOW" updater (PR #309) swaps the .app the same way, so a Mac user accepting an update very likely keeps running the OLD host until they reboot, while the UI reports the new version. That is inferred from this incident and NOT yet measured on the updater path - verify it before fixing it. Logged P1 in TODO.md, with the Linux systemd user unit to check for the same shape. PROVEN AFTER THE RESTART, on Tim's own Mac, end to end over the real DHT: the same request that returned 12,174,566 bytes before returned 3,529,005 after (29%), and a .wma asked for the way worklet/quality.js actually asks for it (mp3 @320 on iOS) came back as MP3 rather than ASF. Test devices and the test track were removed afterwards; the library is back to its original 209. ## 2026-08-18 - The desktop installers bundle their own ffmpeg, built audio-only and LGPL Tier: T1 (packaging and a host resolution path; no wire or storage change). The capability negotiation this feeds - `caps.timeOffset`, gated on `hasFfmpeg()` - already exists to describe hosts with and without a transcoder, so a desktop host gaining one is that mechanism working rather than a cross-peer change. THE GAP. host/Dockerfile has baked in a static ffmpeg since image 0.2.45, so Umbrel and Start9 hosts transcode. A DESKTOP install never got one: nothing in desktop/package.json bundled a binary, nothing set PEARTUNE_FFMPEG, and host/transcode.js read `PEARTUNE_FFMPEG || 'ffmpeg'` - a bare PATH lookup that a normal Mac or Windows machine fails. So every transcode from a desktop host did nothing. And nothing errors: transcode.js degrades to raw bytes on purpose, so a format the phone cannot decode arrives whole and plays as nothing, which the UI renders as "paused" forever. The 2026-08-14 .wma bug, on every desktop library there is, and the same morning's PR #380 widened it to .ogg on iOS. I FIRST FRAMED THIS AS "brew install ffmpeg on the mac-mini" AND TIM CORRECTED IT. That fixes one machine, his, and leaves every other desktop user broken. He also remembered bundling work having been done - correctly, but in pearcinema, which vendors per-platform binaries for its desktop packaging and refuses GPL ones on purpose. PearTune had inherited the container half of that idea and never the desktop half. The correction turned a one-command workaround into the actual fix. AUDIO-ONLY, BUILT FROM SOURCE, and the numbers are why. pearcinema's ready-made BtbN LGPL builds are ~110MB each for Linux and Windows, because they are general-purpose and carry every video codec. PearTune's desktop installers are 100-175MB today, so reusing those would have roughly doubled the Windows (129MB) and Linux .deb (100MB) downloads to ship code a music app can never execute. Enumerating only the demuxers/decoders host/adapters/folder.js indexes and the encoders/muxers host/transcode.js emits gives 3.8-5.0MB per platform - about 21x smaller. Tim chose this over the fast path knowing it cost more work. LGPL IS A REQUIREMENT, NOT A PREFERENCE. PearTune is MIT and these binaries ship INSIDE the installer, so a GPL ffmpeg would drag copyleft across the app - the same reason CLAUDE.md refuses the holesail packages. lame is LGPL and opus is BSD, so libmp3lame + libopus need no `--enable-gpl`. Asserted on the ARTIFACT rather than the flags, in two places: the build refuses to emit a binary whose own `-version` banner reports `--enable-gpl` or `--enable-nonfree`, and check.sh re-asserts it per binary afterwards. RESOLUTION, in host/ffmpeg-bin.js: explicit PEARTUNE_FFMPEG (trusted verbatim, an operator outranks our guess), then a bundled binary relative to the host's own location, then PATH. The packaged candidate deliberately walks OUT of app.asar, because a binary inside an asar cannot be spawned - electron-builder stages it beside the archive instead. That arithmetic is one `..` away from silently degrading to PATH, so it is pinned by a test against the real packaged layout. THREE SILENT TRAPS FOUND WHILE BUILDING IT, each of which alone would have shipped the bug back: - prepack.js wiped ALL of desktop/vendor/ before re-vendoring host/. vendor/ffmpeg is BUILT, not copied, so the wipe deleted it every time - locally right after building it, and on the Mac between the rsync and the pack. Now it wipes only the three directories it owns. - build-mac.sh excluded desktop/vendor from its rsync, so the Mac that actually packs the .dmg never received the binaries. Two --include lines, placed BEFORE the exclude because rsync takes the first matching rule. - electron-builder treats a missing extraResources source as a WARNING. A fresh clone has no binaries (they are gitignored), so the build would have succeeded and shipped an installer that cannot transcode. scripts/ffmpeg/require-binaries.js now refuses, reading what to demand from package.json so the two cannot drift, and rejecting a truncated binary as well as an absent one. NOT COMMITTED: ~17MB across four platforms, and a fresh copy per ffmpeg bump would sit in history forever. Rebuilt per ffmpeg VERSION rather than per release, and they persist on disk between releases. Same call pearcinema made. The guard above is what makes that safe. VERIFIED BY RUNNING, NOT BY LOOKING. Each binary was exercised on its own platform against all eight formats the scanner indexes - linux natively, both darwin slices on the mac-mini, win32-x64 on a real Windows 10.0.26200 box Tim booted for it (wine agreed beforehand, and is the fallback in check.sh). 32 conversions, all non-empty, all LGPL clean. Then end to end: electron-builder packed the app, the binary landed at resources/ffmpeg/linux-x64/ffmpeg and ran, ffmpeg-bin.js resolved to exactly that path from the real packed app.asar layout, and host/transcode.js turned a .wma into 24494 bytes of mp3 through it. Both new guards were also reverted deliberately and confirmed to fail before being restored. STILL OPEN: the Mac desktop host currently RUNNING on the mac-mini predates this and has no ffmpeg, so Tim's own library keeps the bug until it is rebuilt and reinstalled from this branch. Logged in TODO.md. ## 2026-08-18 - iOS has no Vorbis decoder, so .ogg joins the transcode list Tier: T1 (a playback-policy correction with a measurement behind it). worklet/quality.js listed `ios: ['wma']` on the ASSUMPTION that AVPlayer handled everything else host/adapters/folder.js indexes. Wrong since the list was written, and invisible because the failure mode is silent: a format the player cannot decode raises no error, the bytes arrive, nothing comes out, and the UI shows "paused" forever. That is exactly how the .wma case reached a user on 2026-08-14. MEASURED RATHER THAN REASONED. One second of a 440Hz sine encoded into each of the eight containers the scanner indexes, run through AVAssetReader inside a booted Simulator (iOS 26.5, PearTune-Test) with the decoded PCM frames counted. Not AVURLAsset.isPlayable - that is a readback, and trusting readbacks is the mistake this codebase keeps paying for. mp3 45504 m4a 44100 flac 46080 wav 44100 aiff 44100 opus 48000 decoded ogg 0 tracks, "Operation Stopped" NO DECODER wma 0 tracks, "Cannot Open" NO DECODER So `ios` becomes `['wma', 'ogg']`. Five of the six assumed-fine formats really were fine; the sixth was not, and there was no way to know which without running it. THE .opus ROW IS THE INTERESTING ONE and it decides how the list is keyed. A .opus file is Opus-in-Ogg and it decodes; a .ogg file is Vorbis-in-Ogg and it does not. The Ogg CONTAINER is supported, the Vorbis CODEC never has been. Key on the suffix, not the container, and never "fix" this by adding 'opus' for symmetry. Android is unchanged and that asymmetry is real, not a copy-paste oversight: ExoPlayer ships both Vorbis and Opus extractors. VERIFICATION DISCIPLINE, because the first run said something false. It failed EVERY format identically, iOS-decodes-nothing, which is absurd on its face for mp3 - and the cause was that `simctl spawn` does not inherit the caller's working directory, so every relative path missed. Absolute paths, and mp3/flac confirmed decoding, BEFORE any row was read as a finding. The tool is committed at scripts/ios-format-check/ with that trap written into its header, so the next person re-runs the measurement instead of re-deriving it, and test/quality.test.js now pins every row. The new test fails against the old `['wma']` list and passes against the fix. CONSEQUENCE WORTH TRACKING: .ogg on iOS now needs a host-side transcode, so it needs ffmpeg on the host. The Umbrel image has had it since 0.2.45, but the Mac desktop host runs from node source and `which ffmpeg` on the mac-mini says not found. iOS + .ogg + a Mac host is still silence until that is fixed - logged in TODO.md, and it is the same open item that already covered the .wma case there. ## 2026-08-18 - The App Store listing copy is committed too, and the Android asymmetry is real Tier: T1 (repo layout; no runtime code changes). The follow-up split out of the ios/ decision below, decided the same day. `metadata/ios/` held four untracked .json files and no .gitignore entry covering them - the same accidental shape `ios/` was in, and for the same reason nobody had noticed. They are not incidental: `scripts/release.sh` step 3 of the App Store publish feeds this directory to `asc metadata apply`, so these files ARE the description, subtitle, keywords and "what's new" that Apple publishes and users read. Text that ships, invisible to review. Committed, with the split drawn where the generation is: - `app-info/en-US.json` (name, subtitle, privacy URL) and `version/default/en-US.json` (the canonical listing copy) are hand-maintained SOURCE. - `version//en-US.json` is GENERATED per release from `default/` plus `release_notes.md`, and committed anyway. Unlike `ios/`, this cannot go stale: the generator writes a versioned directory only when one does not already exist, and every release has a new version number, so a committed copy is a durable record of exactly what Apple was told for that release rather than a snapshot waiting to drift. - `screenshots/` stays ignored, unchanged - it is regenerated from a real library's album art and was deliberately kept out (see the .gitignore comment). THE ANDROID ASYMMETRY IS NOT A BUG. `metadata/android/` has no listing copy to commit because the Play listing text is edited in the Play Console; nothing in `release.sh` uploads it. Checked rather than assumed, since the obvious reading of "iOS has listing files and Android does not" is that Android's went missing. FOUND AND REMOVED WHILE READING: a dead block in `release.sh` that copied `release_notes.md` to `metadata/ios/en-US/release_notes.txt` behind an `[ -d metadata/ios/en-US ]` guard. That directory has never existed here, so the block had never run - and it read as "the iOS notes are kept in sync here", the same mostly-true-comment failure that the entry below is about. Deleted rather than repointed: the notes already reach Apple through the `whatsNew` injection in step 3, and a second copy would only be a second thing to drift. ## 2026-08-18 - ios/ is committed, like android/, because the file that ships was the untracked one Tier: T1 (repo layout and a verify-gate guard; no runtime code changes). Decided with Tim 2026-08-18, from the loose end that PR #377 uncovered. THE STATE BEING FIXED. `ios/` was neither committed nor ignored. Exactly ONE file under it was tracked - `ios/PearTune.xcodeproj/project.pbxproj` - alongside eighteen untracked ones, and the root `.gitignore` had no `ios` entry at all. So the directory was one accidental `git add` from some earlier session, and everything else about it was invisible. WHY THAT MATTERED. `ios/PearTune/Info.plist` is the file whose LITERAL `CFBundleShortVersionString` and `CFBundleVersion` decide what App Store Connect receives, and it was on the untracked side. The 2026-08-17 rejected upload is the direct consequence: app.json and project.pbxproj both read 1.0.2 build 5 on both machines, the archive/sign/export all succeeded, and Apple got an IPA stamped 1.0.1 build 3. Nobody thought to ask which file the IPA reads, because every comment in the tree - `scripts/release.sh` step 0, `scripts/ios-appstore.sh`, `test/ios-version.test.js` - said `ios/` was generated output and not committed. Those comments were 6/7 true. A comment that is mostly true about a directory is worse than no comment, because it stops the question being asked. THE DECISION: commit `ios/` in full, the same way `android/` already is. Nineteen files, 228 KB, no secrets (the entitlements file is an empty dict, there is no DEVELOPMENT_TEAM or provisioning profile in the pbxproj, and `.xcode.env` says in its own header that it is meant to be versioned). The alternative considered and rejected was gitignoring `ios/` entirely and running `expo prebuild -p ios --clean` on every release so the tree could never be stale. That is purer, but it moves an Xcode-project regeneration onto the release-day critical path, and release day is the worst time to discover that a newer Expo generates a project the Mac cannot archive. THE COST, AND THE GUARD THAT PAYS IT. Committed generated output drifts. `ios/` is regenerated by `expo prebuild` from `app.json`, and the release script prebuilds only when `ios/` is MISSING - which it now never is. So editing `app.json`'s `ios` block does not reach an archive on its own. `test/ios-version.test.js` is the gate: it no longer skips, and on top of the version literals it now asserts the bundle identifier and EVERY key declared under `expo.ios.infoPlist` actually reached the committed plist. Proved it bites rather than merely passes: adding `NSLocalNetworkUsageDescription` to `app.json` without prebuilding turns the suite red, and removing it turns it green again. Also corrected in the same change: the stale "ios/ is NOT committed" comments in `scripts/release.sh` (two) and `scripts/ios-appstore.sh` (one), the root `.gitignore` (which now says out loud that the absence of an `ios` entry is deliberate, and ignores the two CocoaPods outputs that only ever exist on the Mac), and this repo's `CLAUDE.md`. NOT changed, and logged in TODO.md instead: `metadata/ios/` - the App Store listing copy - is untracked in the same accidental way. It is the same shape of problem (text Apple receives, invisible to review) but it is listing metadata rather than the native project, so it gets its own decision. ## 2026-08-11 - A blind relay cannot attribute a session to a device, and it is not meant to Tier: T0 (a correction of the record; no code change). Moved here from TODO.md on 2026-08-11 because it is a FACT, not a task - it could never be ticked off, so it sat on the open list making it longer and less true. Re-verified on the way in: still accurate. MY EARLIER CLAIM WAS WRONG AND IS WITHDRAWN. On 2026-07-27 I read the relay's `relay:pair` lines as identifying peers and concluded "none of these are mine, so the relay's load is other people". That does not follow, because those keys identify nothing durable. `new HyperDHT()` is constructed with **no keyPair** on both sides - `host/server.js:121` (options carry only bootstrap and an optional port) and `src/bare.js:929` / `:2059` - so a node's DHT key is **random per process**. The host uses its real identity only for `server.listen()`. So the keys in relay logs are per-process node keys, not device keys and not host keys. Proved live rather than argued: the Pixel was demonstrably relaying to the Umbrel while the Umbrel's host key `se4t5s91` appeared ZERO times in the relay's logs. CONSEQUENCE, and the reason this is worth keeping: Tim's own hosts and phones ARE in that traffic, under keys nobody can match. Anyone reading relay logs later must not re-derive the comfortable version. Real attribution would mean correlating relay timings against host-side connect logs - or accepting that a blind relay legitimately cannot know who its users are, which is the point of it being blind. ## 2026-08-01 - macOS installs by drag, not by installer wizard Tier: T1 (packaging; no host-logic, wire or data change). Confirmed by Tim after asking why the Mac install had no wizard like the seeders'. THE COMPARISON THAT PROMPTED IT WAS OFF BY ONE PRODUCT. The wizard is the SEEDERS' (PearCircle/PearCal seeder), which ship a .pkg with Distribution.xml and a postinstall script. PearCal's DESKTOP app targets `dmg`, exactly like PearTune's - so the two desktop apps already agree, and it is the seeders that are the odd ones out. WHY THE SEEDERS NEED A PKG AND PEARTUNE DOES NOT: a seeder installs things AROUND the system - a LaunchAgent, a payload under /usr/local/lib, a root LaunchDaemon for its updater, ownership changes, `launchctl bootstrap` to register the service. None of that is possible by dragging an icon; it needs a package whose scripts run as root. PearTune's Mac app installs NOTHING outside its own bundle: you drag it to /Applications and it registers its own login item from inside the app on first launch. There is nothing for a wizard to do. DECISION: keep the .dmg. It needs no admin prompt, leaves nothing scattered around the system, matches PearCal's desktop app, and a wizard would add friction without buying anything macOS can actually use - it could not make the host always-on, which is the one thing that would justify it (a LaunchAgent dies with its login session; see the 2026-08-01 hardened-runtime entry and the measurement behind it). THE COST, STATED: no install-time setup, and no uninstall path at all until desktop/installer/macos/uninstall.sh (PR #313) - which is the gap this question surfaced. The uninstaller now ships inside the bundle. Also accepted: PearTune is deliberately INCONSISTENT across platforms - Windows shows an installer and asks for admin because it registers a service, Linux's .deb sets up a systemd unit, macOS does neither - because each platform's capability genuinely differs. REJECTED: a .pkg wizard (an admin prompt on every install, a second target to build and notarize, and still no always-on host), and surfacing "Uninstall PearTune…" in the tray menu (Tim: keep the .dmg as-is). ## 2026-08-01 - macOS is hardened and notarized; the "it breaks LAN pairing" claim was false Tier: T2 (packaging/signing; no host-logic or wire change). Branch feat/macos-notarization. THE CLAIM THAT WAS WRONG, and it had hardened into fact across three files (desktop/scripts/build-mac.sh, DECISIONS, TODO): "notarization requires hardened runtime, and hardened runtime silently blocks HyperDHT's raw UDP, so a notarized build breaks same-network pairing." It was inherited from PearCal and never measured here. It steered the desktop release for weeks and was the stated reason Mac users would right-click-to-open forever. DISPROVEN TWICE, both against the real thing: 1. Socket level - a hardened and an unhardened host run side by side on the mac-mini: identical. 18 UDP sockets and 10 established outbound DHT peers each, dashboard 200 on both. 2. End to end - an Android phone PAIRED with a hardened, current-code PearTune.app over the LAN (device online, granted by qr-pair, full scope, against the real 209-track library), and then again with the fully NOTARIZED build. AND THE ENTITLEMENTS WERE ALREADY THERE. desktop/build/entitlements.mac.plist already carried allow-jit, allow-unsigned-executable-memory, allow-dyld-environment-variables, network.client and network.server - the file had been written for hardened runtime from the start. The flag had simply never been flipped. DECISION: hardenedRuntime true, notarize on, credentials staged to the mac from scripts/.env. Gatekeeper now reports `accepted / source=Notarized Developer ID` with the ticket stapled, so the "unidentified developer" prompt is gone for good. TWO IMPLEMENTATION NOTES worth keeping: * electron-builder 25 REFUSES `notarize.teamId` in package.json - it wants APPLE_TEAM_ID in the environment, and says so only as an easily-missed info line mid-build. * a failed dmg build leaves its temp image ATTACHED, and every later build then dies with a misleading `hdiutil resize ... Exit code 35`. Detach it before re-running. THE METHOD LESSON, which is the durable part: this was found by measuring a claim nobody had tested, on the day it was finally cheap to test. A comment repeated into two other files is not evidence. The same day produced three sibling cases - digests that proved bytes arrived but not that a store opened, a service reporting healthy while serving an empty library, and an installer reporting success while installing nothing. ## 2026-07-30 - CORRECTION to the entry below, plus: the token host no longer follows the view Tier: T1. Proposal `proposals/2026-07-30-session-home-regardless-of-view.md`. Branch fix/session-home-any-view. CORRECTION FIRST. The entry below (#283) claimed to stop a blended device and a focused one both playing. The code and its tests are right, but the claim was too broad: it only bites when both devices' session rows live on the SAME host, and they often did not. `sessionTarget()` sent a blended device to the ELECTED home and a focused device to whatever library it was FOCUSED on. Those coincide always with one paired library - which is why every test passed - and about half the time with two. The hardware rig that proved #283 passed because the focused phone happened to be pointed at the elected host; pointing it at the other library would have reproduced the bug with the fix in place. Recorded rather than quietly folded in, because the merged PR text reads stronger than what shipped. THE FIX: `sessionTarget()` now returns the elected home in BOTH modes. `merged` still selects the SCOPE - which row - and only the HOST is unified, so two devices paired to the same libraries always coordinate through one authority and #283 applies by construction rather than by luck. The decision is pure in `worklet/hosts.js` as `sessionHost()`, next to `electHome`, so the property that matters - a blended device and a focused device resolve the SAME host - is a unit test rather than a hardware ritual. Consequence: a focused device's queue lands on the elected host rather than the one it is reading. That is already what a merged queue does (a host stores a queue opaquely and never dereferences a trackId), so nothing host-side changes. It did expose one thing worth fixing alongside: `sessionTakeover`'s resume fallback used the SESSION client to look up a track's position, which is now the wrong host in single mode too - it routes by the track's owning library instead. With ONE paired library, which is nearly every install, the behaviour is byte-for-byte unchanged. Still NOT fixed, and still logged: devices with DIFFERENT pairing sets elect different hosts and cannot be arbitrated at all. That needs a person identity spanning hosts, and `personId` is minted per host. ## 2026-07-30 - The play token is per PERSON; only the queue is per scope Tier: T1/T2. Proposal `proposals/2026-07-30-one-token-across-scopes.md`. Branch fix/session-across-scopes. Closes the follow-up left open by PR #272. Context: `session:{ownerId}` and `session:merged:{ownerId}` were two independent tokens, each with its own activeDeviceKey and generation, so a compare-and-set on one was invisible to the other. A phone in the blended view and one focused on a single library could each be "the active player" and both play indefinitely. Nothing odd was needed to reach it - merged mode turns itself on at 2+ paired libraries, so the two phones only need different pairing sets. Choice: a successful claim in one scope also takes the OTHER scope's row for the claiming device - activeDeviceKey moves, generation bumps, and the queue/index/position/modes over there are left exactly as they were. The loser learns the same two ways a same-scope loser always has: the session-superseded push, and its next setSession being refused because the row no longer names it. WHY NOT COLLAPSE THE TWO ROWS, which is the obvious fix: the queues genuinely differ. A merged queue carries foreign trackIds tagged with their owning library; a single-library queue carries local ones. One host can be both a person's single-library home and the elected merged home, and sharing a queue between those would break "Play here" across scopes - a worse bug than the one being fixed. Separating the two things the row conflated is the whole idea: the queue answers "what", which is scope-dependent; the token answers "who is playing", which is not. A TEST ASSERTED THE OLD BEHAVIOUR AS A GUARANTEE - "the merged CAS is independent: a claim in one scope does not bump the other generation", with a line reading "a TABLET takeover of the MERGED session leaves the single-library holder untouched". That sentence IS the bug, written down as an intention in 2026-07-20. Replaced rather than deleted, with the reversal and the reason in the test, so nobody re-derives the original. Consequences: no schema change, no new methods, no wire change. An old host simply does not supersede across scopes (today's behaviour); an old client is superseded by mechanisms it has understood since 2026-07-17. NOT verified on hardware - it needs one phone paired to two libraries and one to a single library, a rig neither of Tim's phones is in. ## 2026-07-30 - Playlists push too, closing the last known live-update gap Tier: T2 (a new push kind). Branch fix/playlists-live-update. Third and last entry in the "no push exists for user state" thread; the favorites and request entries below are the others. Context: written up as a follow-up when the favorites push shipped, and picked by Tim off the backlog rather than found in use - the gap was identical and known, so fixing it while the pattern was fresh beat waiting for him to hit it. All five mutations push `playlists:changed` to the person's other devices: create, rename, delete, add, setTracks. Choice worth recording: the payload carries `{ id, reason, libraryId }` and the client still RE-READS rather than patching. A create/add/remove all shift the counts the summaries carry, so a patch would have to reimplement the host's own arithmetic on the client - the exact bug class the merged favorites union has (a removal on one host is only a removal if no other host still has it). The OPEN PLAYLIST DETAIL is a second surface, and it gets a `refreshKey` prop rather than a remount. A remount would reset `pl` to null, and PlaylistScreen renders its loading state on null - which is precisely the Favorites ghost-shell regression from earlier the same day. Its effect OVERWRITES instead of clearing, so the rows stay up until the new ones land. Applying that lesson before it could repeat is the whole reason the prop exists rather than a key bump. Consequences: needs a new host and a new app. Verified against a real host across all five mutations, including that the writing device is never told about its own change. NOT verified on hardware. ## 2026-07-30 - The owner request queue is pushed when it SHRINKS, not only when it grows Tier: T2 (a new push kind). Branch fix/manage-requests-live. Extends the same "no push exists for user state" thread as the favorites entry below. Context: Tim reported that requests do not update on the Manage page in real time. MEASURED FIRST, and the measurement contradicted the report in a useful way: filing a request from the TCL made it appear on the Pixel's Manage page instantly, twice, with the count tracking 1 -> 2. Arrivals were never broken. What is broken is the queue SHRINKING - a request that LEAVES. Two ways it leaves, and neither told an owner anything. `resolveRequestAndNotify` pushed `request:resolved` to the REQUESTER only, so resolving on the dashboard left every owner's Manage list showing the row as pending. `request.delete` (a requester withdrawing) pushed nothing at all. So the queue only ever grew, which reads exactly as "does not update in real time". Choice: both sites push `requests:changed` to every OWNER-scope device. A deliberately empty payload beyond a reason - the owner list is a live read, and "something changed" is the whole message; carrying the row would invite the client to patch a list it did not fetch. The three sites that push to owners (arrive, withdraw, resolve) now share `notifyOwners` in presence.js rather than each deciding for itself who an owner is. The arrival case had that loop INLINE and the other two had nothing, which is how they drifted apart in the first place. Rejected: a toast on `requests:changed`. A row leaving is not worth interrupting an operator over, and a resolve they just performed on the dashboard would toast back at them. Consequences: needs a new host and a new app; an old host sends nothing and the app behaves as it does today. Verified by integration tests against a real host (withdraw and resolve both push, and the owner's live read agrees). The ARRIVAL path is verified on hardware; the two new ones are not, because a second owner device or dashboard access was needed and neither was to hand. ## 2026-07-30 - The host pushes favorites changes to a person's other devices Tier: T2. Proposal `proposals/2026-07-30-favorites-live-update.md`. Branch fix/favorites-live-update. Context: Tim favorited an artist on the Pixel and it did not appear in the TCL's Favorites list; same for a track; "if I dismiss the app and reopen it, everything gets updated and looks to be in sync". An earlier report the same day was the SAME bug from the other side - the Favorites list had the item while the heart on the album/artist/song was empty. Cause: NO PUSH EXISTED FOR USER STATE AT ALL. The host pushes `library-renamed`, `devices:changed`, `request:new`, `request:resolved` and `session-superseded`, and nothing for favorites - so a connected device was never told. The worklet's `favorites()` is already a live host read (its on-disk copy is only the offline fallback), so the staleness was purely in WHEN the app asked: on mount, on `host:connected`, on a merged host joining, on a host switch, and on its own toggle. A phone sitting connected hits none of those, which is exactly why a relaunch fixed it. That also explains the list-and-heart disagreement: the Favorites screen refetches its resolved rows when opened, but the id sets that fill the hearts do not. One root cause, two symptoms - so the UI handler refreshes BOTH, and clearing only one is called out in the comment as the thing that produced the first report. Choice: `presence.notifyOwner` gains `exceptDevice`, and `fav.set` pushes `favorites:changed` to the person's other devices. Not to the writer - it already re-rendered optimistically and a push would fight that. The exception is per DEVICE, not per channel, because one device can hold two overlapping connections during a reconnect. Rejected: having the client patch its id sets from the payload instead of refetching. Cheaper, but in merged mode the sets are a UNION across hosts, so a removal on one host is only a removal if no other host still has it - a refetch cannot be wrong. Also rejected: a toast. A favorite you made on your own other phone is not news worth interrupting for; it should just be right. Consequences: needs BOTH a new host and a new app - an old host never sends the kind and the app behaves exactly as before, which is the current behavior rather than a regression. Playlists have the identical gap and are NOT fixed here; TODO.md. ## 2026-07-30 - A playing device without the token reconciles; "Continue listening" is per-device Tier: T2. Proposal `proposals/2026-07-30-one-device-plays.md`. Branch fix/one-device-plays. Context: Tim reported two of his devices playing different tracks at once and confusing the "Continue listening" card. Reproduced on the TCL + Pixel against the Umbrel, both granted to one person. The framing in the report - "merged identity, or one-device-at-a-time?" - turned out to be the wrong question: BOTH already exist and both work. Every user-state row is already keyed by a host-derived owner, and the `session:{ownerId}` token with its generation compare-and-set already supersedes cleanly, sub-second, in both directions when both phones are online. The bugs were elsewhere, and there were two of them. Choice, part one: a device that is PLAYING but does not hold the token re-checks the session on the heartbeat it was previously skipping, and either adopts, stops or claims. The claim used to be a ONE-SHOT fired on the transition into playing (`app/index.tsx:387`), never retried, and a non-holder never spoke to the host about the session at all - so a phone that was offline when Play was pressed played untracked forever. Measured: 70s of two phones audibly playing, and the offline one still going after it reconnected while its own screen said the other device was active. The decision is pure in `worklet/session.js` so it is testable without a phone. The load-bearing detail is that "stop" is gated on the HOLDER saying `playing`. The token deliberately persists as last-known after a device stops, so another device can still offer "Play here"; without the gate a token left behind days ago would kill offline playback the moment the phone found wifi. Rejected accordingly: the strict reading, where a device that cannot claim refuses to start. That is airtight and would break playing downloads on a plane. Choice, part two: the resume row gains `playedAt` (the client's clock, when it LISTENED) and `deviceKey` (host-derived, never a param), and `latestResume` orders by playedAt and prefers the asking device. The old ordering was `updatedAt`, stamped on arrival - so a phone draining an offline outbox landed hours-old positions in FRONT of the phone playing right now. Measured: the Pixel playing "Strange Encounters" while its own card offered the TCL's "Interspace at 3:34". Note this half needs no simultaneous playback at all; one device returning from offline is enough. Alternatives for part two: scope the resume KEY per device (rejected - it forks positions, and two phones resuming one track sharing a position is the point of host-as-hub), or drop the card's cross-device reach entirely (rejected - a freshly paired phone should still get one, so the person-wide newest stays as a fallback). The explicit cross-device affordance is the "Playing on " / "Play here" handoff card, which is untouched. Consequences: additive fields, no migration; old rows fall back to `updatedAt` and are treated as belonging to no device. A stopped device still loses its queue, because the reconcile stops through the same `stop()` the existing handoff uses - consistent, but it means an offline phone loses its queue on reconnect. Left as the proposal's open question. The merged-vs-single session split (`session:merged:{owner}` vs `session:{owner}`) is a SECOND way two devices can both play, real but not what Tim hit, and stays in TODO.md. ## 2026-07-28 - Demo mode: a bundled library, built as a third browse branch Tier: T2 (app-only). Proposal `proposals/2026-07-28-app-review-demo.md`. Branch feat/demo-mode. WHY AT ALL. PearTune has no account and no cloud. An App Store reviewer installs it, opens it, and is asked to scan a pairing code from a dashboard on a machine they do not own. From where they sit the app does nothing, which is Guideline 2.1 and a fair reading. The same wall meets anyone who installs the app before setting a server up, which is the honest reason it stays in the product afterwards rather than being stripped before release. WHAT WAS REJECTED, and stays rejected: a pairing link in the review notes. `PAIR_TTL_MS` is five minutes and `host/pair.js` closes the window on the first successful pair, so a link is short-lived AND single-use - useless to an unattended reviewer and broken for the second of two. The fix for THAT would be a standing invite, which is the exact negation of what `host/pair.js` calls the security boundary for a first pair, and it would ship to every host including the ones strangers run for their friends. Not worth making to solve a problem in Apple's review queue. THE SHAPE. Demo mode is a MODE FLAG consulted by browse, a THIRD branch beside `mergedMode()` in tracks/albums/album/artists/artist/genres/genre/search and the -Tracks variants. The alternative - a fake in-process "client" - needed no browse changes at all, but `client/index.js` exposes ~20 methods (favourites, resume, counts, playlists, identity, avatar, leave) and most are meaningless with no host. Twenty stubs is a wider surface than a handful of branches, and every one is a thing that can lie. PLAYBACK NEEDED NO NEW SERVING CODE. `worklet/shim.js` already answers a request for a track it finds in the audio cache straight off disk - ranges, seeking, backpressure, offline. So a demo track is simply a cache entry that was never streamed, installed PINNED so the LRU cannot evict the one library the app can always play. Two consequences fell out of that: * `pinned` had to become a `createSink` option rather than a `setPinned` call after `commit()`. Commit runs the eviction pass, so a small cache cap deleted the file it had just written - caught by the unit test, not by the phone. * The shim gained ONE new predicate, `hostless(id)`. A demo id has no host, so no lease of its could ever be fresh (the 14-day gate would black the bundled music out), and no host could ever render its cover at another size (the art store's one-size rule would leave the grid on placeholders). Both gates take the same exemption, so they take the same predicate. THE ONE THING HARDWARE TAUGHT. The cover shipped as `cover.jpg` and never arrived: React Native's Android asset packager routes recognised image types into `res/drawable-*`, where expo-asset can only hand back a RESOURCE NAME - `ENOENT: ... assets_demomusic_cover`. Anything it does not recognise goes to `res/raw` and copies out to a real file, which is why the five `.mp3`s worked first time. The file is now `cover.bin` (unmodified JPEG bytes, non-image extension) with `bin` added to metro's assetExts. Recorded in `assets/demo-music/LICENSE.md` and `metro.config.js` so it is not "tidied" back. NAMING MOVED BEFORE THE DEMO CHOICE (Tim, 2026-07-28, on first use). The first cut put "Try it without a server" on the INTRO card, so a demo user reached a working library having never been asked who they were - and Connect from the demo banner then had to rewind them through the naming cards to avoid pairing them nameless. The order is now intro -> name -> whose library -> pair, and the demo is the THIRD answer on the whose-library card ("I don't have one yet") rather than an escape hatch on the intro: "where is your music?" has three honest answers and "nowhere yet" is one of them. Consequences: Connect from demo mode is now a single card (the code), and startDemo persists the name to settings.json - on the pairing path it would have ridden into pair(), but the demo path has no pair() to carry it, so without that write it lived only in React state and a relaunch lost it. AND THE BACK GESTURE. Backing out of the add-a-library card closed the app: the 'back' listener only walked cards when `!state.host`, and adding a library over a RUNNING app never matched. Found via demo mode's Connect button, but it was equally true of Settings > Libraries > Add on a paired phone, and had been since multi-host shipped. NOT TOUCHED, deliberately: the grant store, the identity keypair, the pairing window, the wire protocol and every host. The demo library is not in `hosts.json` and never will be - it is supplied by `listHostsData()` when the flag is on - so switchHost, removeHost and every connection path stay blind to it. Pairing a real library retires the demo and gives the ~18 MB back. ## 2026-07-28 - Em dashes: field SEPARATORS are exempt from the suite-wide ban Tier: T0 (copy/style, no behaviour). Recorded here because it is a standing exception to a rule in `/home/tim/peerloomllc/CLAUDE.md`, and an exception nobody wrote down gets "fixed" by the next sweep. THE RULE: no em dashes in any output, including user-facing strings. It is a good rule and the 2026-07-28 Settings pass (PR #233) removed five real violations - a toast, a quality description, the identity-confirmation note, a tooltip and a status label. All prose, all correctly hyphenated now. THE EXCEPTION (Tim's call, offered three ways): eight remain, and every one is a SEPARATOR between two data fields rather than prose: - `src/ui/App.jsx` retro "classic" player skin - the LCD marquee and playlist rows, `3. Title — Artist` - the search / music-request line joins in RequestsView and the owner's requests panel, `Name — Artist` WHY THEY STAY. The classic skin is a facsimile of a 90s amplifier readout, and `Title — Artist` is what the thing being imitated actually displayed; hyphenating it changes the look of the feature, which is the entire point of the skin. The list joins have a second reason: a spaced hyphen reads as part of a hyphenated title the moment an artist or album name contains one of its own, which is common. HOW IT IS ENFORCED. Not by this entry, which nobody greps. Each of the three sites carries a comment saying the em dash is deliberate and must not be swept. A future `grep -n '—'` lands on the comment before it lands on the string. ## 2026-07-24 - Multi-host: removing the ACTIVE library stranded the survivor (fixed) Tier: T1 (app logic, no wire/grant/protocol change). Branch fix/multihost-remove-active-strands-survivor, PR #171. The MIRROR of the 2026-07-23 add-library bug (#166), in the remove direction. SYMPTOM (Tim, on the TCL): with two libraries paired + merged, deleting the one currently ACTIVE removed it correctly (revoked on its dashboard) but the OTHER library then disconnected and stuck at "Reconnecting" / "Not connected", even though its host was reachable. Reproduced on hardware. ROOT CAUSE: removeHost's wasActive branch promoted the next host via connectTo(next). But that host already had a live POOL connection, and Hyperswarm dedups one connection per peer - so connectTo re-dialed, never got a fresh 'connection' event, attachActive never fired, and the promotion hung forever. Exactly the dedup trap #166 documented, hit from the other side: #166 was add-strands-the-old-active; this is remove-active-strands-the-pooled-survivor. FIX: promotePoolToActive(next), the mirror of demoteActiveToPool. When the promoted host is already pool-connected, ADOPT that live connection into the active slot - reuse the SAME client + conn (no reconnect, no second Protomux channel), null the pool entry's client so its close handler goes inert, take the topic membership over as active (joinActiveTopic re-grabs the same live session, idempotent), and wire an active-style close handler. removeHost tries the adopt first and only dials cold when the pool has no live connection (safe - no existing conn = no dedup). 3+ LIBRARIES: promotePoolToActive also refreshes the merged view (emit merged:updated + conditional rebuildIndex, like demoteActiveToPool/attachPool). With 3+ libraries the view STAYS merged after removing the active one (it only collapses to single-host at <2), so without that refresh the promoted host could read stale pool state in the blend. No-op at 2 libraries (mergedMode is already off there). VALIDATED on the TCL (fixed debug APK built + installed): - 2 libraries: pair Umbrel + Mac (merged, both connected) -> remove the active Mac -> survivor Umbrel went straight to Active/connected and browses. Before the fix the same steps stuck it at "Not connected". - 3 libraries: pair Umbrel + Mac + a throwaway laptop host (all merged, all connected) -> remove the active laptop host -> BOTH survivors (Umbrel + Mac) stayed Active/connected AND the blend stayed merged (did not collapse). Confirms the 3+ path. verify green 472/472. NOT unit-tested (bare.js multi-host globals aren't isolable, same as #166). ## 2026-07-24 - Relay-ops watch: host-wipe DHT reachability (measured; controlled wipe recovers <1s) Tier: T1 (host announce hardening, no wire/grant/protocol change). Branch fix/host-early-reannounce, PR #169. CONTEXT: after a `--network host` container wipe on 2026-07-23 the Umbrel was briefly unreachable over the DHT until a re-wipe fixed it. Item was "confirm a host wipe reliably leaves the container DHT-connectable." Tim chose to reproduce on the live Umbrel. METHOD (all off-host, from Tim's laptop): a topic-visibility probe - boot a HyperDHT node, `dht.lookup(hostTopic(hostKey))`, report whether the host's own announce record is found + time-to-first-hit. This measures the exact symptom (announce propagated?) WITHOUT needing a grant, since lookup is not firewalled. Probe/monitor scripts in the session scratchpad (dht-probe.js, dht-monitor.js, wipe-capture.js). FINDINGS: 1. BASELINE: the live host was VISIBLE, first hit 162ms. Probe validated. 2. RESTART-ONLY (same key, non-destructive, both phones stayed paired): monitored the key continuously across a `docker rm -f` + redeploy - NEVER went invisible. WHY: `dht.lookup` reads the stored ANNOUNCE RECORD, which storing nodes cache for a TTL (~20 min, defaultMaxAge). A same-key restart is faster than that TTL, so the residual record masks the gap. CONCLUSION: a restart CANNOT reproduce the wipe symptom - only a WIPE (new key = brand-new topic with zero prior records) can. 3. FULL WIPE (fresh key 9putst3f…, both phones un-paired): auto-captured the new identity from the dashboard the instant it came up, then timed visibility. DHT-VISIBLE in 0.9s (130ms first hit, settling ~30ms), stable. The symptom did NOT reproduce. So the 2026-07-23 incident was a rare, self-healing transient, not a deterministic bug. REAL LATENT WEAKNESS FOUND (separate from the symptom): the host announces once at startup, then re-announces only every 10 min (TOPIC_REANNOUNCE_MS), and `_announceTopic` swallows failures. So IF a startup announce silently fails to propagate against a cold routing table, the host stays unfindable for up to 10 min. That is the shape of the incident. FIX (belt-and-suspenders, not a reproduced-bug fix): TOPIC_EARLY_REANNOUNCE_MS = [20s, 60s, 120s] - ready() schedules a few early one-shot re-announces before the 10-min cadence, shrinking the worst-case unfindable window from ~10 min to <2 min. unref'd (never hold the process open), cleared in close(). +1 test asserting the timers are scheduled. verify green 472/472. NOT hammered with repeated wipes (each un-pairs both phones); one clean controlled wipe + the code weakness were enough to act. Both phones re-paired to the wiped host afterward (TCL driven via adb; Pixel by Tim). ## 2026-07-23 - Multi-host: adding a 2nd library stranded the 1st as "offline" (fixed) Tier: T1 (app logic, no wire/grant/protocol change). Branch fix/multihost-demote-orphan. SYMPTOM (Tim, fresh test): pair host A (active, connected) -> Add Library -> pair host B. B connects, but A now shows OFFLINE/greyed in the merged Library, even though A's HOST dashboard still shows the device connected, and pull-to-refresh never recovers it. ROOT CAUSE (two layers, both confirmed on hardware via the TCL + two throwaway laptop hosts): 1. The pair flow makes the new host ACTIVE (useLibrary(B) + attachActive(B)). attachActive did `const c = await ensureClient()` - but ensureClient RETURNS THE EXISTING singleton `client`. So it re-attached the SAME client object to B's connection, ORPHANING A's connection: no client wraps it, nothing tracks it. connectedLibs() (active client + pool clients) therefore excludes A -> "offline". 2. It cannot self-heal: Hyperswarm dedups one connection per peer, so a later merged read that joinPoolTopic(A)s never gets a NEW 'connection' event (A's connection already exists), so attachPool never fires for A. Stuck until A's connection happens to drop + redial. Hence pull-to-refresh is inert. FIX: on a genuine host SWITCH (prev active is a different, still-paired, still-connected host), attachActive now uses a FRESH `makeClient()` for the incoming host instead of the reused singleton - so the OUTGOING client stays attached to its own live connection - and then `demoteActiveToPool(prevHost, prevClient, prevConn, prevLib)` adopts that live connection+client into the pool (reuses the client, no reconnect, no second Protomux channel), wires a pool-style close handler, and refreshes the blend. First-pair and same-host reconnect keep the ensureClient singleton path unchanged. Also fixed: onPaired now `setTab('library')` so a successful pair lands on the Library, not back on Settings (Tim's secondary note). GOTCHA that cost a build cycle: capture the outgoing host from `currentHost`, NOT activeLibraryId - the pair/switch flows call useLibrary(new) BEFORE attachActive, so activeLibraryId is already the incoming host. VALIDATED on hardware (TCL + two laptop hosts): pair A -> add B -> mergedStatus = both CONNECTED, and the `pool:adopted` log fires. Before the fix: A offline, no pool:adopted. NOT unit-tested (bare.js multi-host globals aren't isolable); builds green, full suite is the standing memory-pressure housekeeping item. ## 2026-07-23 - Blind relay DEPLOYED + key baked: the off-LAN backstop is live (phase 2 activated) Tier: T3 (proposal 2026-07-23-blind-relay). Branch feat/blind-relay-activate, PR #163. DEPLOYED: the relay node runs on a US DigitalOcean droplet (Ubuntu 24.04, Node 20, `peartune-relay` systemd service, active + enabled = survives reboots). Provisioned by scp'ing `relay/` to the box + running `relay/deploy/bootstrap.sh` (installs Node, service user, systemd, starts it, prints the key). Public key: `qshao3eawtzecrt5p7buswr4meyyhw6q6b51qtxazd8wwfdp8uqy`. Private seed lives only on the box (`/var/lib/peartune-relay/relay.seed`, 0600) + backed up to Tim's laptop (`/home/tim/peerloomllc/peartune-relay-seed-BACKUP.txt`, 0600) + his password manager. Provider chosen after a detour: Hetzner's cheap CX/CAX lines are EU-only (bad latency for a US relay), so a US DigitalOcean droplet by card (Tim considered BitLaunch/Lightning, decided against the detour). CORRECTION 2026-07-28: this entry originally said "$6 droplet", which was wrong and would have doubled every capacity number computed from it. The box is DO's **$4 Basic tier, 500 GB/month** of transfer. Measured on the droplet: 458 MB RAM, a 10 GB disk (8.7 GB usable on /dev/vda1), 1 vCPU, nyc1. Only `s-1vcpu-512mb-10gb` carries that shape - the $6 plan is 1 GB RAM / 25 GB disk / 1 TB transfer - so the specs identify the plan on their own. NB the DO metadata service (`169.254.169.254/metadata/v1.json`) does NOT expose a size slug, so short of the billing page this is the strongest evidence obtainable from the box itself. Overage is $0.01/GiB. See the 2026-07-27 capacity entry in DONE.md, which already computed its figures against 500 GB and therefore still stands. ACTIVATED: `protocol/relay.js` RELAY_PUBLIC_KEY_Z set to the deployed key. `relayThroughFor` now returns a real key on the fail path, so the phone escalates through the relay when a direct punch aborts (the live Mac-mini 0/4 case) - direct-first, gated by the Settings toggle (default on). No host change (phase-1 finding). `test/relay-policy.test.js` "no key baked yet" flipped to assert the key decodes to 32 bytes. VERIFY: relay-policy.test.js 6/6 + all 3 builds green. The full 471-test suite was NOT re-run to completion this session - the dev machine was memory-pressured (28/31 GB from Tim's apps) and the DHT-testnet tests (relay/transport/integration) crawl there; it passed green (471) earlier today and this change is a single constant that cannot affect network tests. NOTE for next session: run a full `npm run verify` when the machine is less loaded, to close the gate formally on master. PHASE 3 VERIFIED ON HARDWARE (2026-07-23, Pixel over cellular). Two parts: (a) DIRECT-FIRST HOLDS: with the normal build, both hosts connected over cellular via CONSISTENT punches (dht.stats.punches consistent, random:0) and the relay stayed OUT - the relay only engages when the direct punch fails, exactly as designed. (b) THE RELAY PATH WORKS END-TO-END: a THROWAWAY force-relay build (relayThroughFor returns the key always; reverted after) connected to BOTH hosts in ~3s over cellular, and the RELAY NODE's own stats confirmed it carried them - `sessions.active:4, pairings.active:2, streams.active:4` (phone paired to Mac-mini + Umbrel THROUGH the relay, 4 live encrypted streams), pairings.matched climbing 2->4->6. So phone -> relay -> home server over cellular is proven. NB the PHONE-side `dht.stats.relaying` counter read 0 even while relaying - it tracks a different relay role; the RELAY NODE stats (journalctl on the droplet) are the ground truth. Method: CDP-drove the Pixel (svc wifi off, cold-launch, read via the bridge), relay stats via `ssh root@ journalctl -u peartune-relay`. REMAINS: the DISCLAIMER work (TODO) now that PeerLoom actually runs a relay; a full `npm run verify` on master when the machine isn't memory-pressured (housekeeping). ## 2026-07-23 - Blind relay phase 2: the phone offers the relay (direct-first) + a privacy toggle; inert until keyed Tier: T3 (proposal 2026-07-23-blind-relay). Branch feat/blind-relay-phone-toggle. Phase 2 of 3. Phone-side only; NO host change (see phase-1 finding). Lands the app plumbing; behavior is UNCHANGED until phase-1 deploy bakes the relay key. WHAT SHIPPED: the phone's ONE Hyperswarm (src/bare.js ensureSwarm) now passes `relayThrough` - as a FUNCTION, not a static key, so the toggle + the baked key are read LIVE per connect. The function is `relayThroughFor` in the new `protocol/relay.js`: returns the relay key only when `(force || randomized)` AND `useRelay` AND a key is baked, else null. `force` is what Hyperswarm sets after a HOLEPUNCH_ABORTED, so this is direct-FIRST: null on the normal attempt, the key only after the direct punch fails. `randomized` (double-randomized NAT, direct can never work) relays from the first attempt, matching Hyperswarm's own default gate. THE PRIVACY TOGGLE (Tim, 2026-07-23): a Settings -> Connection switch "Use the relay when direct fails", DEFAULT ON. Off = pure peer-to-peer, PeerLoom infrastructure never touched, accept that a 0%-punch network will not connect. This is the honest control for a decentralization maximalist. Terminology pinned in the same conversation: the DHT is the DECENTRALIZED part (no company, the app's only transport - "disable the DHT" is not a privacy dial, it just breaks the app); the RELAY is the one centralized piece (sees metadata: which keys talk + bandwidth), so the toggle governs the relay, not the DHT. `useRelay:true` in DEFAULT_SETTINGS; `setUseRelay({on})` IPC (persist-only - the live relayThrough fn means no reconnect to apply); a `toggle` switch in App.jsx's Connection section. INERT UNTIL KEYED: `protocol/relay.js` RELAY_PUBLIC_KEY_Z is null until the relay is deployed to a VPS and its key baked. While null, relayThroughFor always returns null - the phone passes no relayThrough and nothing relays, so this phase is a safe no-op on master. That is why it is mergeable + verifiable now and needs no hardware gate (no behavior change to verify); the hardware 0%-punch gate is phase 3, AFTER the key is baked. DIAGNOSTICS: the Settings -> Connection report already dumps `dht.stats` incl. `relaying {attempts, successes, aborts}` (#148); updated the stale "we pass no relayThrough" comment - the counters now climb when the swarm escalates to the relay, so "direct failed but relaying.successes rose" is the relay working. The diagnose() dial itself still uses no relayThrough (it measures the raw punch on purpose). TEST: test/relay-policy.test.js (+6) pins every branch of relayThroughFor (direct-first, escalate-on-force, randomized-NAT, toggle-off, no-key-inert) + that no key is baked yet (a reminder to update it after deploy). verify green. DEPLOY RUNBOOK: relay/DEPLOY.md + relay/deploy/bootstrap.sh (idempotent VPS provisioner); Hetzner CX22 recommended (bandwidth-bound; 20 TB/mo). NEXT: Tim provisions -> bootstrap prints the key -> bake into protocol/relay.js -> phase 3 hardware verify on a genuinely-0%-punch scenario. ## 2026-07-23 - Blind relay phase 1: the relay NODE, and two load-bearing findings that shrink the rest Tier: T3 (proposal 2026-07-23-blind-relay, approved by Tim 2026-07-23). Branch feat/blind-relay-node. Phase 1 of 3. New infra only; NO app or host code change ships in this phase. WHAT SHIPPED: `relay/` - a standalone Node daemon (`relay/relay.js` `RelayNode`, `relay/index.js` entry, `relay/identity.js` seed-persisted keypair, Dockerfile + systemd unit + README for a public VPS). It is a HyperDHT node on a stable keypair that runs a `blind-relay` server: on each inbound `createServer` connection it `relay.accept(conn, { id: conn.remotePublicKey })`s, and blind-relay pairs two peers by a shared token and `.relayTo()`s raw UDX between them. It is BLIND (end-to-end Noise; it sees ciphertext + which-keys-talk + byte volume only) and app-agnostic (one relay key can back the whole suite). It MUST run on a routable public IP - a home-NAT box (Umbrel/Start9) is only as reachable as the host we are rescuing. THE LOAD-BEARING QUESTION, RESOLVED (this is why phases 2-3 are small): does hyperdht `relayThrough` compose with Hyperswarm's `swarm.join`, or must the phone fall back to raw `dht.connect(hostKey, {relayThrough})`? ANSWER: it composes NATIVELY. hyperswarm@4.17 takes `relayThrough` in its constructor (index.js:28,62), and `toRelayFunction` (index.js:683) makes a plain key resolve to null UNLESS forced - so direct is always tried first. On a connect error it sets `peerInfo.forceRelaying = true` and resets attempts when `shouldForceRelaying(err.code)` (index.js:225,670), which fires on exactly `HOLEPUNCH_ABORTED` (our measured 12%/0% failure), `HOLEPUNCH_DOUBLE_RANDOMIZED_NATS`, `REMOTE_NOT_HOLEPUNCHABLE`. So Hyperswarm ALREADY implements direct-first + escalate-on-punch-abort per peer. Phase 2 is one option on the existing swarm at src/bare.js:1226 - no raw dht.connect fallback, no second media path. THE SECOND FINDING: the HOST needs NO code change. When the phone escalates it puts relayThrough in its handshake payload (connect.js:455); a stock `dht.createServer` host relays on `remotePayload.relayThrough` alone (server.js:410), dials the relay itself (server.js:664), and `_relayConnection` sets `hs.relayToken` BEFORE the prepunch timer arms - so the holepunch-abort teardown `if (hs.relayToken === null) hs.rawStream.destroy()` (server.js:428) is a no-op for a relayed stream, and the pair handler clears the prepunch timeout (server.js:682). A stock host SUSTAINS a relayed connection it never configured. Setting relayThrough ON the host would be worse: server.js:410 would then be always-true, relaying EVERY connection (a wasted relay dial even when the punch works). So the phone controls escalation; the host is untouched. This removes the host-image / Umbrel-redeploy gate the draft assumed. TWO IMPLEMENTATION GOTCHAS (measured while writing test/relay.test.js, do not re-derive): 1. The relay's forwarding is BIDIRECTIONAL-on-demand: a relay stream learns a peer's UDX address only from a packet THAT peer sends (blind-relay's `_onfirewall` hook). So "A sends, B echoes" DEADLOCKS in a raw test - B is unreachable until it sends first. A real hyperdht connection dodges this because the Noise handshake is bidirectional. The test has both ends send. 2. Peer raw streams for the direct-drive test use `dht.createRawStream()` (NON-framed). `{ framed: true }` (what hyperdht's own client uses internally) did NOT deliver data across the relay in the raw harness; the relay forwards bytes regardless, and framing is a peer concern hyperdht manages itself in the real path. Non-framed is correct for the harness. TEST: test/relay.test.js (+4) - (1) the relay pairs two peers by token and forwards bytes both ways, deterministically (direct blind-relay drive, no punch race); (2) relayThrough composes with a normal dht.connect without breaking it (on a testnet the punch always wins, so this proves direct-first, not relaying - a testnet CANNOT fail a punch, which is why the "relay carries it" proof is the phase-3 hardware gate); (3) identity determinism + 0600; (4) RelayNode requires a keyPair. verify green. DEPLOY: needs a public VPS (Tim chose "cheap VPS" 2026-07-23). Nothing deploys or changes behavior until phase 2 bakes the relay's public key into the app and the phone passes relayThrough. Phase 1 is inert infra. ## 2026-07-22 (night) - Hyperswarm transport phase 1: host ANNOUNCES the topic, it does not run Hyperswarm Tier: T3 (proposal 2026-07-22-hyperswarm-transport, PR #153 = approved). Branch feat/hyperswarm-transport-phase1-host. Phase 1 of 4. Host-side only; no wire, grant, revoke or media change. WHAT SHIPPED: the host announces a discovery topic derived from its key (`hostTopic(hostKey)` = blake2b(NS || hostKey), protocol/ids.js) so a phone can find it by DHT LOOKUP and keep retrying until a hole-punch lands - the off-LAN fix. `host/server.js` ready() now, after `server.listen(keyPair)`, does `dht.announce(topic, keyPair)` and re-announces every 10 min (unref'd; the DHT record TTL is ~20 min); close() unannounces. `_onconnection` and `_firewall` are BYTE-FOR-BYTE unchanged. THE DIVERGENCE FROM THE PROPOSAL, and why. The proposal said "replace createServer+listen with Hyperswarm+topic join" on the host. I did NOT run Hyperswarm on the host; I kept createServer and added a raw announce. Two reasons, the second one measured, not guessed: 1. Two dht servers on one keypair deadlock. A Hyperswarm with server:true runs its OWN dht.createServer on the host keypair. Standing one up beside the host's existing server is exactly the deadlock host/server.js already documents. (Deleting the old server and relying only on Hyperswarm's would have been the alternative - which runs into #2.) 2. Hyperswarm's firewall wrapper cannot carry our ASYNC grant lookup. host/gate `_firewall` awaits a Hyperbee lookup. Hyperswarm's `_handleFirewall` (node_modules/hyperswarm/index.js:315) is synchronous: it treats the firewall's return as a boolean, and an async firewall returns a Promise, which is always truthy, so it BANS the peer (peers.get(...).banned = true) on the very first connection. The DHT still admits that first connect (it awaits the real Promise), but the ban makes line 313 deny the peer's every RECONNECT synchronously. MEASURED on a hyperdht testnet 2026-07-22: allowed key connects once, then reconnect #2 and #3 are refused. Fatal for a music app whose phone reconnects constantly. Two scratch scripts proved both halves (first-connect works; reconnect banned). So the host stays on createServer (async firewall intact, admission unchanged, no ban bug), and ONLY the PHONE gets Hyperswarm's ConnectionManager in phase 2 - which is where the retry-forever + keepalive that actually fixes off-LAN lives. The host is a server; it never needed Hyperswarm's client-side retry anyway. `dht.announce` is precisely the piece of Hyperswarm the host DOES need (topic discoverability), minus the firewall wrapper that breaks us. BACKWARD COMPAT (the migration invariant): raw `dht.connect(hostKey)` still reaches the host because `server.listen(keyPair)` is unchanged - an un-upgraded phone keeps working against an upgraded host. A NEW phone (phase 2 topic-client) needs a host that announces the topic, i.e. this release. So: ship the host, confirm old phones still connect, then ship the app. Reversible in either order. SECURITY: hostTopic = hash(hostKey) exposes nothing new. Anyone who knows the host key could already dht.connect it; a topic only lets the same peer find it by lookup. The firewall remains the SOLE admission control - a topic-stranger is refused exactly as a key-dialer is. Pinned by test/transport.test.js: a granted device finds the host over the topic; a stranger on the topic gets no connection; raw dht.connect still works. +4 tests (3 transport, 1 hostTopic determinism), verify green at 459. DEPLOY: host change - does nothing until the host runs it. Needs the Mac host redeployed from source (host/redeploy-mac.sh) and/or the Umbrel image rebuilt + redeployed (Tim-gated). Until then hosts simply do not announce the topic and everything runs as today. ## 2026-07-22 (evening) - Off-LAN measured: the hole-punch is ~12% on Google Fi, so retries cannot save it Tier: T1 note backing a still-open T3 (the relay). Data gathered by driving Tim's Pixel over USB with wifi disabled (= Google Fi cellular = off-LAN; USB keeps adb alive), running the diagnostics 6 times via CDP - no blind taps, the safe method Tim approved 2026-07-22. RAW NUMBERS (each attempt is ~one independent hole-punch; F = HOLEPUNCH_ABORTED at a deterministic ~10.5s): Umbrel single-attempt 2/21 = 10% | connected within 4 tries 2/6 sessions Mac single-attempt 3/19 = 16% | connected within 4 tries 3/6 sessions COMBINED single-attempt punch 5/40 = 12% BOTH libraries up in the same session: 1 of 6. The phone is firewalled with NO public address every run (CGNAT). The abort TIMING is deterministic (~10.4s Umbrel, ~11.6s Mac); only the OUTCOME is random - the classic marginal punch on hard NAT. THE CONCLUSION IS ARITHMETIC. At a 12% per-attempt rate, 95% reliability needs ~22 attempts (~236s). Retries were the right first move and they DID turn "never" into "sometimes", but no sane retry budget makes a 12% punch dependable. This is the evidence the relay decision was waiting for: a PeerLoom-run relay (hyperdht relayThrough), used only when the direct punch fails, is the fix that makes "works anywhere, no VPN" true on a real carrier. Google Fi is an MVNO (T-Mobile/US Cellular) with aggressive CGNAT, i.e. a normal user's network, not an edge case. TAILSCALE stays a red herring for the DHT path (the LAN-address gate, DECISIONS earlier today). Tim's observation that toggling it changed which host connected is consistent with the ~12% coin-flip: the toggle just spent time during which another attempt happened to land. DECIDED: gather-only for now (Tim). NEXT is the relay proposal (T3) when Tim is ready - this entry is its evidence base. ## 2026-07-22 - Off-LAN: why a VPN does not rescue the app, and diagnostics before a transport change Tier: T1 (the diagnostics screen itself: read-only, no wire change). The TRANSPORT decision it exists to inform is T3 and still unmade - deliberately. THE FINDING, from hyperdht 6.33's source, not from guessing. Tim, off-LAN on cellular: with Tailscale ON he can open the Umbrel DASHBOARD in his browser, but the app still cannot connect. Both facts are explained by one line in `node_modules/hyperdht/lib/connect.js`: if (c.lan && relayed && clientAddress.host === serverAddress.host) { ... try the peer's local addresses } hyperdht only attempts a peer's LAN/tailnet addresses when BOTH ENDS APPEAR TO COME FROM THE SAME PUBLIC IP. Off-LAN the phone's public address is the carrier's and the host's is Tim's home connection, so the host's tailnet address (100.100.71.117) is carried in the handshake payload and NEVER TRIED. The browser reaches the dashboard because that is a plain TCP connection to a routable tailnet address; the app is key-addressed and goes through the DHT, which will not use that path. NB the tailnet address is not filtered as "reserved" (bogon's isReserved says false for 100.64/10) - it survives into the payload and is still never dialled. CONSEQUENCE: "turn on a VPN" is NOT a workaround for this app today, and must not be documented as one. That correction matters because the earlier write-up (TODO, 2026-07-20) reached for exactly that advice. ALSO CORRECTED: the old note claimed HyperDHT has NO relay fallback. FALSE for 6.33 - `relayThrough` exists on BOTH `dht.connect(key, {relayThrough})` and `dht.createServer({relayThrough})`, and the DHT tracks `stats.relaying {attempts, successes, aborts}`. We pass no relayThrough today, so nothing is ever relayed; using it needs a node with a public address that both ends can reach, i.e. infrastructure PeerLoom would run. DECIDED, with Tim, 2026-07-22: DIAGNOSTICS FIRST, transport change second. The off-LAN failure can only be reproduced on his Pixel (the TCL has no SIM), so every previous attempt was blind, and "Couldn't reach your library" is the same sentence whether the DHT never bootstrapped, the host was never found, or a carrier NAT refused the punch - which want completely different fixes. Settings -> Connection now runs the transport for real (a raw hyperdht dial per paired host, using the device's own keypair, no client retries or error mapping in the way) and reports where it stopped, with a Copy report button. WHAT THE REPORT DISTINGUISHES, which the app's normal error cannot: PEER_NOT_FOUND (the server is not announcing - a SERVER problem) vs PEER_CONNECTION_FAILED / CANNOT_HOLEPUNCH (found it, the network would not let us through). It also carries whether this phone is firewalled, whether the DHT knows its public address, and the punch/relay counter DELTA for the run. It deliberately does NOT claim which of "the network refused" or "the server declined this device" happened, because from the client they are indistinguishable - the same honesty #125 established for the pairing copy. NOT DECIDED YET, and both stay open until a real off-LAN report lands: (a) a PeerLoom-run relay via relayThrough - restores "works anywhere, no VPN" but is infrastructure the pitch says you do not need, and the relay sees metadata and carries bandwidth; (b) an address hint so the app can use a tailnet path - bigger than it sounds, because hyperdht is key-addressed, so it means a second transport (our protocol over a plain socket plus a Noise handshake), and it only helps people who already run a VPN. ## 2026-07-20 - Instant library rename via a host PUSH (over the existing presence registry) Tier: T2 (additive, backward-compatible wire extension; no new message type, no new access surface, no grant/revoke change). Branch feature/library-rename-push. The truly-instant follow-up to the on-connect sync (fix/non-active-host-live-rename, same day): that made a rename refresh on reconnect; this makes it refresh the MILLISECOND the operator hits rename, for the active host AND every non-active pool host at once. WHAT: the host broadcasts a `library-renamed` push to every connected device the moment setLibraryName() changes the name, carrying `{ libraryId, libraryName }`. Self-describing (libraryId) so the client updates the RIGHT stored host record - a non-active pool host relabels exactly like the active one. - host/presence.js: new `notifyAll(kind, data)` - broadcast to every live connection of every device (same swallow-a-bad-sender contract as notify()). - host/server.js setLibraryName(): `if (changed) this.presence.notifyAll('library-renamed', {libraryId, libraryName})`. Only on an actual change (same-name save pushes nobody). - src/bare.js makeClient() onPush: a new `library-renamed` branch mirrors syncHostNames' logic (find the host record by libraryId, renameHost + emit host:renamed) - just push-driven. The UI already relabels the header/switcher/merged chips off host:renamed for any hostKey, so NO UI change was needed. WIRE: NO new message type - it's a new `kind` STRING within the existing media push (framing.js type 5, which already carries session-superseded). Fully backward-compatible: an old client ignores an unknown kind; an old host never sends it (and the client's identity()/on-connect sync still covers those hosts). Doc comment in framing.js updated to list both kinds. SECURITY: no new surface (see host/presence.js's own argument): the push only rides a channel the firewall already admitted and register() runs AFTER the grant check; a revoked device's connection is destroyed, which unregisters it, so it can never receive one. The payload is a libraryId + name the device already knows. TESTS: +5 (3 presence notifyAll units: broadcast-reaches-all, no-connections no-op, throwing-sender swallow; 2 integration over real DHT: rename pushes to a connected device with the right libraryId + identity.get still carries it, and a same-name save pushes nobody). 384 tests, verify green. DEPLOY: this is a HOST change - it does nothing until the host runs the new code. The client half ships in the APK. Needs the host image rebuilt/redeployed (Umbrel, Tim-gated) or the Mac node-source host restarted to take effect; until then hosts fall back to the on-connect sync. HARDWARE-VERIFIED (Mac side, 2026-07-20): Mac host redeployed to the new code (host/redeploy-mac.sh); a fresh debug APK built from this branch + installed to the TCL (packaged bundle confirmed to carry the library- renamed onPush handler). Renamed the Mac library "My Library"->"Mac Music" via its loopback dashboard - the TCL's merged source-filter chip relabeled INSTANTLY (no pull-to-refresh, no reconnect), and reverted live on rename-back. Deploy tooling committed: host/build-image.sh (multi-arch GHCR build + auto-pin) and host/redeploy-mac.sh. UMBREL side still owed: needs the 0.2.10 image built/pushed + the sudo redeploy (Tim). ## 2026-07-20 - Non-active host's library rename now refreshes on the same trigger as the active host Tier: T1 (client worklet only; no wire/host/grant/protocol change). Branch fix/non-active-host-live-rename. GAP: PR #92 made the ACTIVE host's dashboard rename refresh live (identity() polls getIdentity() on every host:connected). The merged-library work added syncHostNames() so a rebuildIndex() also relabels EVERY connected pool host - BUT a complete-blend host:connected (the common case: the active client reconnected while the pool hosts stayed up) reloads browse WITHOUT a rebuild (App.jsx only calls refreshMerged when some host is still disconnected). So on that path loadIdentity() refreshed the active host's name but nothing resynced the pool hosts - a non-active host renamed on its dashboard stayed stale in the source-filter chip until a pull-to-refresh or a full reconnect-driven rebuild. FIX: identity() (which rides loadIdentity(), fired on every host:connected) now, in merged mode, also fire-and-forget syncHostNames() for the connected NON-active pool hosts. So a pool host picks up its rename on the exact same trigger the active host does - parity, no extra machinery. syncHostNames already emits host:renamed per change and the UI already relabels the header/switcher/chips for any hostKey. NOTE - still not instant on a STABLE connection: like the active host, this refreshes on reconnect / pull-to- refresh, not the millisecond the operator hits rename while everything stays connected. Truly-instant for ALL hosts would be a HOST PUSH on rename over the existing presence registry (the session-superseded pattern) - a separate host change that needs a redeploy to verify; logged as a follow-up, not done here. Verify green (379 tests + builds). ON-DEVICE smoke owed: rename a non-active pool host's library on its dashboard, trigger a reconnect (background/foreground or network flip), confirm the merged chip relabels. ## 2026-07-20 - Handoff support is per-host, not a single app-wide flag Tier: T1 (client worklet only; no wire/host/grant/revoke/protocol change). Branch fix/per-host-session-support. BUG: the cross-device "Play here" session handoff tracked host capability in ONE global boolean `sessionSupported`. The first time any host answered `ENOMETHOD` to a session RPC it flipped false and NEVER came back, so a single stale host - one old host in a blended (merged) library, or a host you'd since switched away from in single mode - silently disabled the handoff for EVERY host until an app restart. This is the "one old host in a blend shouldn't disable the handoff globally" iOS follow-up (applies to both clients; the fix is in shared src/bare.js). FIX: replaced the flag with `sessionUnsupported`, a Set of libraryIds whose host answered ENOMETHOD, plus `sessionSupportedFor(lib)` / `markSessionUnsupported(lib)`. sessionTarget()/sessionReady() now also return `lib` (the target host's libraryId - the elected home in merged mode, the active host in single mode), and every session op (heartbeat push in saveQueueState, sessionActivate, sessionInfo, sessionTakeover) gates on and marks support by that lib. Unknown/offline (no target lib) = optimistically supported (no card flicker); markSessionUnsupported(null) is a no-op. So an old home host degrades ONLY itself, and once it disconnects and a session-capable host is elected home (or you switch active host), the handoff works again. DELIBERATELY NOT DONE: biasing electHome to prefer session-capable hosts. electHome must stay a PURE, deterministic function of the host list so every device independently elects the SAME merged-session home (one generation-CAS authority). Filtering by locally-learned, per-device capability would let two devices pick different homes and split-brain the session. So when the elected home genuinely IS old, the merged handoff is unavailable for everyone consistently (all devices agree) - correct, if not ideal. Verify green (379 tests + builds). ON-DEVICE smoke still owed: needs an actual old (pre-session) host in a blend, which isn't available right now (Umbrel + Mac hosts are both session-capable 0.2.9 / node source). ## 2026-07-20 - Merged library: Settings-row status + rebuild-loop / re-pair fixes Tier: T2 (client UI + worklet only; no wire/host/grant/revoke change). Branch fix/merged-settings-row-status. Polish + correctness surfaced while addressing the stale Settings library-row label from the step-2 revoke test. HARDWARE-VERIFIED on the TCL vs Umbrel + Mac. WHAT SHIPPED: - Settings > Libraries rows are MERGED-AWARE: in the blend they read "In your blended library" / "Offline — unreachable" with a live connection dot, instead of the single-host "Active — connected" / "Tap to switch". Rows are informational in merged mode (the home chips do the filtering); tap-to-switch stays only in single-host mode. Passes `merged` into Settings. - mergedStatus().connected now reports LIVE pool connectivity (connectedLibs()), not build-time mergedConnected - so a revoke greys the chip + row the instant the connection drops, no rebuild needed. trackCount stays index-based (a greyed host's last-built tracks stay browsable). - Prompt greying wired two ways: the UI re-queries mergedStatus on host:disconnected (with a delayed second read for the single-client/pool close race), AND the worklet's pool-close handler pushes merged:updated (covers a NON-active host revoke, which fires no host:disconnected). - removeHost exits merged mode when the paired count drops below 2 (matches the host:switched the UI already flips on). - REBUILD COOLDOWN (20s, force-bypass): a revoked/unreachable host kept `some host disconnected` true, so every single-client reconnect re-triggered a full refreshMerged - an on-device rebuild LOOP (re-fetching catalogs, host:disconnected flood). refreshMerged({force}) rate-limits auto-triggers; pull-to-refresh and pair pass force. Verified: 22s window went from a flood to 1 rebuild, 0 disconnects. - onPaired now enters merged (pairing a 2nd host) or force-rebuilds (restoring a revoked one) so the (re)paired host folds into the blend immediately instead of waiting out the cooldown. HARDWARE: Settings rows showed "Offline — unreachable" (revoked Umbrel) + "In your blended library" (Mac); revoke auto-greyed the chip with no pull-to-refresh; the rebuild loop is gone; re-pair restored the blend (60 albums / 2 libs) and the correct "Tim's Umbrel" name. 368 tests, verify green. onPaired's enter/force path is verify-green + uses the confirmed force-refresh mechanism (not separately re-revoked to avoid grant churn). NOTE: separate BUGS Tim hit on hotel wifi/Tailscale (remove-on-Pixel no-op, orphan host rows, inconsistent re-pair, onboarding-bypass half-identity) are logged in TODO.md, not addressed here. ## 2026-07-19 - Merged library, slice 5: merged-default UI + source-filter chips (STEP 2 COMPLETE) Tier: T3 (merged-library proposal §7). Branch feature/merged-library-ui, stacked on slices 3-4 (#85/#86). The last slice: makes the blended library the DEFAULT view and ships the source-filter chips. HARDWARE- VALIDATED on the TCL against the Umbrel (Navidrome) + Mac mini (folder). NO wire/host/grant/revoke change. KEY REFACTOR (correctness): merged mode is now a boolean flag `_mergedMode`, DECOUPLED from activeLibraryId (slices 3-4 used the activeLibraryId==='_merged' sentinel). Why: any favorites/resume call fires ensureConnected -> connectTo -> useLibrary(realHost), which would silently flip activeLibraryId off '_merged' and drop us out of merged mode mid-session. Now activeLibraryId still names the single client's host (so the single-client "You" features - favorites/resume/counts/session - keep working against the active host, the proposal's "per-filter for now"), while _mergedMode governs the blended browse/streaming. The merged queue uses lib/_merged/queue.json via an explicit queueFile() branch, not the sentinel. WORKLET (bare.js): init() enters merged mode when 2+ hosts are paired (loadCachedIndex for an instant cold render, background rebuildIndex, returns merged status); with 0-1 hosts the app is byte-for-byte single-host. rebuildIndex emits 'merged:updated' so the UI refreshes browse+chips after a background/reconnect rebuild. enterMerged/exitMerged/refreshMerged manage the flag; switchHost drops merged (Settings "focus one host"). DETAIL ROUTING WITHOUT UI THREADING: a new entityLib map (album/artist/genre id -> owning host, built with the slice-4 route maps) lets album/artist/genre detail resolve their host FROM THE ID (libForEntity), so the UI keeps calling album({id}) unchanged - no libraryId threaded through the nav stack. Play routing likewise rides slice-4's trackByAnyId resolver, so the shell queue needs NO libraryId/copies threading (explicit queue-item copies for cold-launch-without-cache failover is a noted deferral). UI (App.jsx): `merged` (per-source status) + `filter` state; a SourceChips row ([All] + one chip per library, offline greyed) under the browse picker, shown with 2+ libraries; browse loaders pass libraryId=filter; the not-connected wall is skipped in merged mode (browse serves from the index); header reads "All libraries · N libraries" for the blend or the focused library's name; pull-to-refresh rebuilds the blend; Recently Added hidden in merged mode (no cross-host addedAt yet). BUGFIX found on-device: the IPC dispatch invokes methods UNBOUND, so `this` is undefined inside them - enterMerged/ refreshMerged's `this.mergedStatus()` (and removeHost's pre-existing `this.listHosts()`) threw. Extracted mergedStatusData()/listHostsData() module functions. HARDWARE: merged home renders "All libraries · 60 albums · 2 libraries"; chips filter (My Library -> "25 albums · Folder"); album detail opens + covers route per host; two albums (Mac's "Courtesy Call", then "Boucle Infinie") played back-to-back in one merged session. 368 tests, verify green (incl. bare-pack). NOT hardware-tested: the revoke-on-one-host case (per-host guarantee unchanged). ## 2026-07-19 - Merged library, slice 4: streaming routing (per-track host + mixed queue) Tier: T3 (merged-library proposal, proposals/2026-07-19-multi-host-merged-library.md §5-6). Branch feature/merged-library-stream, stacked on slice 3 (#85). Wires the merged index into PLAYBACK: a track (or cover) in merged mode streams from the host that actually holds it. NO wire/host/grant/revoke change. URL SCHEME (worklet/shim.js): the loopback URLs gain an optional owning-host segment - /t// and /art//, beside the unchanged single-host /t/, /art/. Pure parseUrl() router (exported, 9 tests) matches the merged 2-seg form first; libraryId is null for the single form, which routes to the single active client EXACTLY as before. Per request the shim picks the client via connFor(): a URL that names a host -> its pool client (hostClient, self-healing); a URL that doesn't -> a resolver (libForTrack/libForCover) in merged mode, else the single active client. Cache HITS never route - served by (namespaced) id alone, host-agnostic, so offline/downloaded playback is untouched. AUDIO uses the durable 2-seg URL (urlFor -> shim.urlForLib) so the player's held URL keeps routing correctly even across an index rebuild mid-session; ART keeps the UI's own 1-seg artBase composition and routes via the libForCover resolver (a mis-resolved cover only shows a placeholder - not worth a UI-wide URL change). bare.js: urlFor({trackId,libraryId,copies}) -> routeTrack() picks the best CONNECTED copy (merge.bestCopy over the live pool set, failing over when the primary host is offline) and mints the scoped URL; ensureHostById revives that specific host; buildRouteMaps() derives coverId/trackId -> owning-host lookups on every index build (cleared on removeHost/forget). The mixed-host queue lands in lib/_merged/queue.json for free (the '_merged' sentinel makes libDir() point there); the shell threading each item's libraryId/copies through saveQueueState is slice 5 - until then urlFor resolves via the index, so merged play already routes. lazy-require: bare-http1 moved to a lazy require inside createAudioShim so shim.js loads in a Node test (the native addon is on-device only) - parseUrl is now unit-testable. KNOWN CAVEAT (pre-existing, not new): subsonic/jellyfin coverIds are the server's RAW ids (not namespaced), so two SAME-KIND servers could collide a coverId in the shared art cache + the libForCover map. Tim's setup (Umbrel Navidrome + Mac folder) doesn't collide (different id formats); trackIds ARE namespaced so audio never collides. This is the same source-scoped-dedup boundary already tracked for multi-SOURCE. 368 tests (+9), verify green (incl. bare-pack). NOT yet hardware-tested - slice 5 (the UI) drives the acceptance run. ## 2026-07-19 - Merged library, slice 3: catalog fetch + index-served browse/search Tier: T3 (part of the merged-library proposal proposals/2026-07-19-multi-host-merged-library.md). Branch feature/merged-library-browse. Slice 3 of 5 - wires the DORMANT slice-1/2 pieces (worklet/merge.js + the src/bare.js connection pool) into a working read-only merged browse. NO wire/host/grant change; the single-host path is byte-for-byte unchanged (every browse method BRANCHES on merged mode, single mode falls through untouched). WHAT SHIPPED: - worklet/catalog.js (+ test/catalog.test.js, 13 tests): PURE fetchAllPages (loop a host's paginated library.list to exhaustion - null / maxPages cap / no-advance guard), fetchCatalog (pull a host's full artists/albums/tracks/genres off one client; rejects on a real drop so allSettled drops that whole host), and the serve helpers paginate/serveList/searchIndex (filter+sort+page and in-memory search over the built index). - src/bare.js: the MERGED MODE flag is the reserved sentinel activeLibraryId === '_merged' (mergedMode()). rebuildIndex() = ensureAll -> fetchCatalog each connected host (allSettled) -> merge.buildIndex -> cache to lib/_merged/index.json + a module var; single-flight; mergedConnected tracks who's in the blend. loadCachedIndex renders a cold launch instantly. Browse LISTS (albums/artists/tracks/genres) + search serve from the index (sorted/filtered/paged in memory - incl. an A-Z Songs list a single Subsonic host can't do); DETAIL reads (album/artist/genre + their track lists) route to the entity's OWNING host via the pool and enrichCopies() tags each track with its merged copies (dedup-key lookup) so slice 4 can fail over. New RPCs: enterMerged / refreshMerged / mergedStatus (per-library connected + trackCount for the slice-5 filter chips + greying). DELIBERATE (noted for review): browse lists come from the index, but album/artist/genre DETAIL track lists come from the owning host (the index holds no per-album track order) - authoritative order + fidelity over a second in-memory structure. A cross-host artist page (one host's albums beside another's for the same artist) stays primary-host-only in phase 1. Merged mode reuses the '_merged' state dir, so favorites/resume in merged mode are per-_merged (empty) = the proposal's phase-2 deferral, not a regression. rebuild-on- reconnect is the UI calling refreshMerged (wired in slice 5); dormant now since nothing enters merged mode yet. 359 tests (+13), verify green (incl. bare-pack). NOT yet hardware-tested - slice 5 (the UI) drives the TCL + Umbrel + Mac mini acceptance run. ## 2026-07-19 - Queue-on-switch continuity (multi-host step 1 polish) Tier: T2 (client/shell only; no wire/host/grant change). Branch feature/queue-on-switch. Closes the last open multi-host item: switching libraries WHILE a track plays now drains the current track then advances into the NEW library's saved queue, instead of loading the new queue only when idle. DESIGN (deferred drain): on host:switched, the WebView calls a new shell method `switchQueue`. If a track is playing, collapse the ExoPlayer queue to just that track (queueClearKeepCurrent - no old-library "next" blip) and hold the new library's snapshot in `switchDrain`; persistence is SUPPRESSED while draining so the new library's queue.json isn't clobbered by the transient one-foreign-track state. When the current track ends (didJustFinish at end-of-queue) OR its buffer runs dry (a new helper reuses decideStarve with dropped:true, since an uncached foreign track can't refill from the new host), `finishDrain` loads + plays the new library's queue. If nothing is playing, switchQueue swaps straight to the new library's queue paused (loadQueueOnPlayer, restoreQueue's guts minus the "don't clobber" guard). Explicit user actions (play/enqueue/stop/playSession) cancel a pending drain. VERIFIED on the TCL (Umbrel + Mac mini, both directions): play -> switch -> current track keeps playing; drain to natural end -> new library's queue plays ("Courtesy Call" on Mac drained, then Umbrel's "Hide No More" took over); drain into an EMPTY new library -> graceful stop. 334 tests, verify green. NOTE: the "keep the buffered track" caveat stands - an uncached foreign track only plays what's buffered/ cached, then the drain-stall advances early rather than freezing (a net improvement over the pre-feature buffer-freeze). ## 2026-07-19 - Multi-host: the library switcher (step 1 of 2) Tier: T2 (client-side storage + UX; no wire/grant/revoke/host change). Branch proposal/multi-host-switcher. Proposal proposals/2026-07-19-multi-host-switcher.md. One phone can now pair to MORE THAN ONE host (an Umbrel AND a Start9) and switch between them from Settings. Chosen with Tim: switcher-first (the merged "gateway to multi-source" is step 2), Settings-only management, and a switch swaps the queue but lets the buffered track play out (graceful-reconnect stance). WHY IT'S SMALL: the hard layers were already multi-host-safe - device identity is host-independent (one keypair, each host gates it), and ids never collide (libraryId = hash(hostKey)). Single-host code was confined to bare.js + App.jsx. NO host-side or wire change. WHAT SHIPPED: worklet/hosts.js (pure host-list ops incl. v1 single-object -> v2 list migration; 13 tests); bare.js hosts.json is a list; per-host STATE (queue/favorites/playlists/outbox/lease/pins) moved under lib// with a one-time flat-file migration; RPCs listHosts/switchHost/removeHost; pair() additive, forget() the full account reset. Settings "Library" section became a multi-library list (switch/add/remove + "Unpair all"). 334 tests (+13), verify green. REFINEMENT (during build): audio/art BLOB caches stay SHARED at the root, NOT namespaced. Their ids are already libraryId-scoped so nothing collides and bytes de-dupe; crucially, re-pointing the audio cache on switch would stall an in-flight track and break the very "keep the buffered track" behavior we chose. removeHost still reclaims a host's downloads (deletes its pinned audio by id, which is host-unique). ON-DEVICE VALIDATION + FIXES (TCL + Umbrel + Mac-mini folder host, 2026-07-19): pair a 2nd host, per-host browse, and round-trip switching all work. Two bugs fixed on hardware (commit e045941): (a) stale active indicator - derive "active" from state.host.hostKey + optimistic UI on tap, don't depend on the host:switched event; (b) a switch aborting on a transient connect failure - switchHost swallows a failed first dial and emits host:switched anyway (on-demand reconnect recovers). SHARED DHT NODE (commit de3ddcf): the transient "host refused" on switch was NOT a host duplicate-refusal (the host admits duplicates); it was each reconnect building a fresh HyperDHT node and dialing off a COLD, un-bootstrapped node (first dial races its own bootstrap, fails fast; retry off the warm node succeeds). Fix: ONE shared HyperDHT node for the worklet, passed to every PearTuneClient (close() leaves it alone; destroyed only on forget). Eliminates the transient and speeds up EVERY reconnect. Exposed + fixed a latent connectTo race (switchHost's direct connect vs ensureConnected's parallel single-flight both racing the `client` global) by pinning the dialed client to a local ref and bailing if superseded. Verified: 4 rapid switches, zero refuse / zero race errors. OPEN (on-device, TCL): the "buffered track drains then auto-advances into the new library's queue while a player is LIVE" continuity - a new ExoPlayer queue-swap flow to validate on hardware. Today a playing track is left untouched (never stops) and the new queue loads when nothing is playing. Everything else is done. ## 2026-07-19 - Host image 0.2.6 (six features since 0.2.5; retires all previews) Tier: T2 (packaging/deploy). Branch release/host-image-0.2.6. Cut from master with EVERYTHING accumulated since 0.2.5 baked in: genre browse (#72), per-device now-playing (#74), recently-added shelf (#75), scheduled auto-rescan (#76), and BOTH device-avatar halves (#78 host/wire/dashboard + #79 phone). Retires the large Umbrel cp preview these rode on (adapters + server.js + media.js + avatars.js + dashboard). Digest sha256:04c38ef5022dbad0c4e2179ae4b084bf2559714872004566643c31bc0f305ccc (OCI image index, amd64+arm64, verified via the GHCR registry API). podman + qemu, pushed to GHCR. Re-pinned all consumers and rebuilt the s9pk (0.2.6, start-sdk verify passed). DEPLOY (open, needs Tim): redeploy the Umbrel off 0.2.6 (sudo docker) to replace the preview container; re-sideload the 0.2.6 s9pk to the Start9 (GUI). ## 2026-07-19 - Host image 0.2.5 (tabbed dashboard + genre browse baked in) Tier: T2 (packaging/deploy). Branch release/host-image-0.2.5. Cut a fresh multi-arch image from master - the first with the tabbed operator dashboard (PR #70) AND genre-browse host support (PR #72), retiring the two Umbrel cp/bind-mount previews those rode on. Digest sha256:4e90d25a411e0062c2fd042b5bdab219f51de8011a5c1ef1ac003a6334e5b6d3 (OCI image index, amd64+arm64, verified via the GHCR registry API). Built with podman + qemu-user-static, pushed to GHCR. Re-pinned all consumers (umbrel + host/deploy composes, start9/Dockerfile FROM, start9/README, docs/host-linux, host/Dockerfile comments) and rebuilt the s9pk (0.2.5, x86_64+aarch64, start-sdk verify passed). DEPLOY (open, needs Tim): redeploy the Umbrel off 0.2.5 to replace the running preview container (sudo docker), and re-sideload the 0.2.5 s9pk to returned-feline (Start9 GUI). Publishing the Umbrel store listing (still on 0.1.0) remains a separate M4 item. ## 2026-07-19 - Genres: a fourth browse view (least → most granular) Tier: T2 (additive, read-only browse - no wire method, no grant/revoke surface). Branch feature/genre-browse. The library picker is now Genres · Artists · Albums · Songs, ordered least → most granular (a genre holds artists, an artist albums, an album songs). Genres is the broadest way in: the grid lists genres, tapping one drills into that genre's ALBUMS (album-first, like an artist page), with a loose-track fallback for a genre no album carries. Play/Shuffle/Queue on a genre plays all its tracks in album order (genreTracks, mirroring artistTracks). NO WIRE CHANGE: the protocol is generic - `library.list`/`library.get` already forward a `type` discriminator - so genres reuse them with `type:'genres'`/`'genre'`. The edits are the worklet method table (src/bare.js: genres/genre/genreTracks), each adapter's list/get type-switch, and the App.jsx UI. - FOLDER (primary): builds a `genres` index at scan from the track `genre` tag - genre id is groupId('genre', lower(name)) so "Rock"/"rock" merge; a genre's cover is its first album's, so the grid is real artwork not grey. Genreless tracks are skipped (no null bucket). Advertises a name sort (FULL_SORTS.genres, reversible). - SUBSONIC: getGenres (optional → empty tab if absent); the genre NAME is its id (what byGenre / getSongsByGenre take); get uses getAlbumList2 type=byGenre with a getSongsByGenre fallback. No genre sort advertised (getGenres is alphabetical-only, like getArtists). - JELLYFIN/EMBY: /MusicGenres for the list; get filters albums by GenreIds with an Audio fallback; uses FULL_SORTS (name sort). NOT yet verified against a live Jellyfin/Emby - folder is validated by tests + on-device; the server adapters follow the documented API and mirror the artists path. UI: the browse `.seg` now holds four pills; padding tightened (scoped to `.pickrow`) + `min-width:0` so the Display button stays on one row. 9 new tests (folder/subsonic/jellyfin). verify green (316). ## 2026-07-18 - Host image 0.2.4 + clean Umbrel redeploy (retires the cp drift) Tier: T2 (packaging/deploy). Branch release/host-image-0.2.4. Cut a fresh multi-arch image from current master - the first image with EVERYTHING accumulated since 0.2.3: multi-folder + its overlap guard, plus items 1/9, generate-and-print, the favicon/wordmark, etc. Digest sha256:ff98c7e43c11e49d13c7872c46f9fe8d8e246b49a52a7a7ef4caceada5b1e411. Re-pinned all consumers and rebuilt the s9pk (0.2.4). WHY NOW: Tim's Umbrel had drifted badly - the 0.2.0 image with the whole current host/ docker-cp'd on top (for the live previews) + a hand-changed mount. Redeployed it CLEANLY off the 0.2.4 image (no cp), preserving /data, so the two-folder library (/library/music primary + /library/downtify, 1670 tracks), the identity, and pairings all survived - and folder.js now hash-MATCHES the repo inside the container (the drift is gone, the image is the source of truth again). The umbrel compose already mounted the whole Downloads -> /library with PEARTUNE_MUSIC=/library/music, so a fresh install sees ALL the Downloads subfolders (music, podcasts, audiobooks, downtify, metube), not just music/ - that narrow /music mount was only on the hand-created running container, never the compose. Start9: 0.2.4 s9pk built + verified; re-sideload when convenient (not yet redeployed to returned-feline). ## 2026-07-18 - Folder source: multiple roots Tier: T2 (host adapter + source config + dashboard). Branch feature/folder-multi-root. The FOLDER source can point at several directories now (e.g. /music AND /audiobooks). Simpler than cross-KIND combinations (still deferred - trackId is source-scoped): a folder is a folder, one adapter, one id scheme. THE LOAD-BEARING DECISION is trackId stability. Ids are hashed from the file's path relative to its root. With one root that was the bare relPath; with several, two files could share a relPath and collide. Fix: the FIRST (primary) root keeps BARE relPaths - so an existing single-/music library does NOT re-key (orphaning favourites/resume/counts) when a second folder is added - and every additional root prefixes a short, stable, PATH-DERIVED tag (sha256(root)[:12]) onto its relPaths. Path-derived (not index-derived) so a root keeps its ids as others are added/removed around it; the one caveat is that removing/replacing the PRIMARY re-keys the additional roots (documented; rare). Also: album keys are root-scoped for the directory-based cases (an "Album X" folder in two roots is two albums), but the albumartist-tag case stays root-agnostic (the same album split across two roots merges); covers resolve against each album's own root (absDir); nested roots are dropped at scan (walked once) and exact dupes/blanks cleaned at save; a missing root is skipped-with-a-log on scan (an unplugged drive does not blank the library) but named on Test. source.json folder config moved `root` -> `roots` (migrated on read). Dashboard: the folder panel is now an add/remove list of folders. 7 new tests (folder-multi + source), verified end to end (set 2 roots via the API -> both scanned + merged). ## 2026-07-18 - Desktop tray app (Electron), Windows + Linux built Tier: T2 (packaging surface; no wire/host-logic change - it wraps host/ unchanged). Branch feature/desktop-app (PR #65). The deferred desktop follow-on from the platform-expansion proposal. - FRAMEWORK: Electron + electron-builder, modeled on pearcal-native/electron (the suite standard). KEY SIMPLIFICATION: PearTune's host is plain Node, so the Electron main REQUIRES it directly - no Bare worklet / barekit-shim / bare-bridge that PearCal needs. scripts/prepack.js stages ../host, ../protocol, ../client into desktop/vendor/ (preserving the relative requires) so the subproject is self-contained for electron-builder. - DASHBOARD ON LOOPBACK: binds 127.0.0.1 and opens in the app's own window, so there is NO password (passwordSource 'none'). The P2P host runs regardless of that bind, so phones pair/stream normally. Simplest good desktop UX; a LAN-exposed dashboard (0.0.0.0 + generated password) could be a setting later. - BUILD STORY: electron-builder cross-builds the Windows NSIS .exe FROM LINUX via wine - works because the P2P native deps (sodium-native, rocksdb-native, hyperdht) ship win32/darwin/linux prebuilds (verified: their prebuilds/ dirs carry win32-x64, darwin-arm64, etc.), so no per-platform compile. Linux is native (AppImage + deb). macOS CANNOT be built from Linux - runs on the mac-mini the other apps use (build:mac), same as PearCircle/PearCal. - SIGNING: UNSIGNED for v1 (Tim). Windows -> SmartScreen "unknown publisher"; macOS unsigned would be Gatekeeper-blocked (so the mac build waits on the mac-mini's identity). package.json#build.mac has identity:null, notarize:false as placeholders to wire later. - VERIFIED on this box: the app launches, the host reaches host:listening in-process, the dashboard serves 200 on loopback with no auth, autodetect responds. Produced PearTune Setup 0.2.3.exe (129M, PE32 NSIS) + PearTune-0.2.3.AppImage (176M) + .deb. Install-test on real Windows + the mac build are the open items. dist/node_modules/vendor gitignored. ## 2026-07-18 - Dashboard password change/reset + source autodetect (items 1 & 9), image 0.2.2 Tier: T2 (host + dashboard; a security-relevant control done conservatively). Branch feature/dashboard-password (PR #63). Two Start9-polish items that share a deploy. ITEM 1 - CHANGE/RESET THE DASHBOARD PASSWORD. createAuth's secret is now mutable (setPassword/verify); POST /api/password swaps it live + writes the dashboard-password file (0600). Decisions: (a) NO current-password re-prompt - the handler only runs for an already-authenticated request (auth.handle 401s the rest), and the case this exists for is a GENERATED password nobody memorised, so re-asking is friction, not security; the session IS the trust boundary (a hijacked session can already revoke everything). (b) REFUSED when PEARTUNE_PASSWORD is set ('explicit' source) - the env wins on restart (resolveDashboardPassword), so a change would silently vanish; the UI says to change the env instead. /api/state carries passwordSource. ITEM 1a - Start9 PROPERTIES: the host writes /start9/stats.yaml (compat v2, masked+copyable) when PEARTUNE_STATS is set (s9pk entrypoint sets it, manifest enables the properties action), so the generated password shows in StartOS Properties, not just logs. ITEM 9 - SOURCE AUTODETECT. host/detect.js probes BOTH .embassy: (Start9) and localhost: (Umbrel host-net / bare) for known music servers and recognises each from a public no-auth endpoint (Jellyfin /System/Info/Public; a Subsonic ping envelope). No platform flag - wrong addresses fail fast, the marker check kills false positives. GET /api/source/detect; the dashboard Music Source panel auto-detects on open and shows found servers as pre-fill chips. Grounded in reality: on returned-feline, jellyfin.embassy:8096 resolves (running) while nextcloud.embassy does NOT (not running) - so probing naturally finds exactly what's reachable. IMAGE 0.2.2 cut with both, re-pinned all consumers; Tim hardware-validated items 1+9 on Start9. FOLLOW-UP FIX (0.2.3): he found the Properties password went STALE after an in-app change - the host wrote start9/stats.yaml only at startup. Extracted writeStartosStats to host/startos-stats.js and call it from POST /api/password too, so Properties mirrors the live password. Kept the app as the one control + Properties a read-only mirror (chosen over moving password management into a StartOS Config form, which would split it from PearTune's config:~ "everything in the dashboard" model). 0.2.3 cut for it. Verified locally end to end (change/refuse; stats.yaml resyncs on change; 5 detect probe tests; 301 total). ## 2026-07-18 - Start9 (StartOS) s9pk packaging Tier: T2 (packaging; no wire/host-logic change). Branch feature/host-start9. Third step of the host-platform-expansion proposal. start9/ adapts the PROVEN pearcircle-seeder StartOS package (0.3.5.x: manifest.yaml + per-arch docker tars + deno-bundled TS procedures, `start-sdk pack`), wrapping the same digest-pinned 0.2.0 host image. Built + `start-sdk verify`-passed locally (1.0 GB universal s9pk, x86_64 + aarch64, podman + qemu for the arm64 apt step). Two deliberate departures from the seeder package: - KEEP OUR PASSWORD (seeder disables its auth via SEEDER_NO_AUTH and trusts the StartOS interface). PearTune's dashboard is a revoke button, so the entrypoint leaves PEARTUNE_PASSWORD unset and lets generate-and-print (PR #60) mint one on the 0.0.0.0 bind, printed to the service logs. Not clear StartOS gates a LAN interface behind a login at all; a revoke button should carry its own lock regardless. Costs nothing (the feature already exists). - NO CONFIG FORM (`config: ~`, like the seeder). Source (Jellyfin/Nextcloud/Subsonic), library name, and pairing are all done in PearTune's own dashboard, same as on Umbrel - avoids the setConfig->container plumbing and the env-vs-source.json conflict. On Start9 the source is a Jellyfin/Nextcloud library already on the box (the only music servers in the Start9 registry). NETWORKING (the crux the proposal flagged): the seeder's README documents that StartOS's podman bridge gives the container an endpoint-independent CONE NAT mapping that DHT holepunching survives FROM CELLULAR/REMOTE (unlike Umbrel's Docker bridge, which killed inbound holepunch and forced network_mode host). So standard StartOS networking should carry the primary pitch; we reuse the seeder's net config rather than reaching for host networking (which 0.3.5.x doesn't expose). KNOWN CAVEAT inherited: a phone on the SAME WIFI as the server often can't reach it (LAN discovery doesn't cross the bridge; routers rarely hairpin) - documented in instructions.md as "turn off WiFi to pair." This bites a music player more than a seeder (home listening), so it's called out prominently. Distribution: SIDELOAD for v1 (Tim, 2026-07-18), not a registry publish. IMAGE BUMP 0.2.0 -> 0.2.1 (forced by the first sideload): the first sideloaded s9pk CRASH-LOOPED on returned-feline with "refusing to start ... 0.0.0.0 with NO password". Root cause: the 0.2.0 image (published in PR #59) PREDATES generate-and-print (PR #60), so the entrypoint's passwordless 0.0.0.0 bind hit the old fail-closed requireSafeBind instead of minting a password. #60's code was merged to master but not in any published image. Fix: rebuilt + published peartune-host:0.2.1 (digest sha256:034dad99...) WITH generate-and-print, re-pinned all consumers (start9/Dockerfile, both composes, docs, host/Dockerfile comments), and rebuilt the s9pk against it. Verified the 0.2.1 image locally mints the password and starts clean (no crash). Lesson: a host-code change is not "shipped" until it is in a published image - the image lags master until rebuilt. The running Umbrel stays on 0.2.0 (it always sets PEARTUNE_PASSWORD, so it never needed generate-and-print; the revoke/media code is byte-identical between 0.2.0 and 0.2.1); the compose points at 0.2.1 for fresh installs. STATUS: HARDWARE-VALIDATED end to end on returned-feline.local (StartOS 0.3.5.1, 2026-07-18) with the 0.2.1 s9pk. Install + generated password (from service logs) + Jellyfin source (http://jellyfin.embassy:8096) + pair-from-cellular + browse + stream + revoke gate ALL passed (host logs: host:pairing-connection -> pair:granted{label:TCL} -> host:connected; then killedConnections:1 + gate:deny device-revoked). The SAME-WIFI caveat is CONFIRMED, not theoretical: a phone on the box's WiFi got gate:allow-for-pairing then the connection died before the pair channel opened (the bridge- NAT symptom); cellular worked every time (tested via the TCL on the Pixel's hotspot, since the TCL has no SIM). This is the main open question for a Start9 RELEASE - home-WiFi listening is a core music- player case and it does not work; host networking would fix it but 0.3.5.x doesn't expose it (revisit on 0.4.x SDK). s9pk + docker-images/ are gitignored. Distribution stays SIDELOAD for v1. ## 2026-07-18 - Linux host packaging + generate-and-print dashboard password Tier: T2 (packaging + a host-code change that does NOT weaken the security model). Branch feature/host-linux-packaging. Second step of the host-platform-expansion proposal. GENERATE-AND-PRINT (the one real code change): requireSafeBind still fails closed - a non-loopback bind with no password does not serve. But "refuse to start" is the wrong behaviour for a bare NAS/Linux install, which (unlike Umbrel/Start9) has no platform to mint ${APP_PASSWORD}. So `resolveDashboardPassword` (host/ui/auth.js) runs first and, for a non-loopback bind with nothing set, MINTS a ~80-bit password, persists it 0600 to /dashboard-password (stable across restarts, like host.seed), and prints it. Order of precedence: an explicit env/flag password always wins; loopback stays password-free; only the non-loopback-and-unset case generates. The invariant is preserved BECAUSE the minted password then satisfies requireSafeBind - a LAN dashboard is still never unauthenticated. Password shape: z32 alphabet (no 0/o/1/l/i to misread off a terminal), grouped xxxx-xxxx-xxxx-xxxx. This keeps the T3 dashboard-auth model intact (2026-07-14) rather than adding a new one - hence T2. Verified at runtime, not just unit tests: ran the host on 0.0.0.0 with no password -> it printed the box + saved the file 0600; restart read the SAME password back (prints a one-liner hint, not the box); the minted password logged in (HTTP 200), a wrong one 401'd, /api/state was 401 without a session. 5 new unit tests (294 total). PACKAGING (no code): host/deploy/ ships a generic (non-Umbrel) docker-compose.yml pinned to the 0.2.0 digest with network_mode: host, a systemd unit (peartune-host.service) + env example, and docs/host-linux.md documents Compose / `docker run` / native+systemd, each ending in the CLAUDE.md revoke-gate acceptance test. NOT shipping a bundled single-file binary in v1 (Tim, 2026-07-18): Docker + the `bin` CLI is enough. Native upgrades track the git repo (no npm publish yet). ## 2026-07-18 - Fresh multi-arch host image 0.2.0 (build tooling, version, deploy) Tier: T2 (packaging; no wire/host-logic/JS change). Branch release/host-image-0.2.0. First step of the 2026-07-18 host-platform-expansion proposal. - BUILD TOOLING: no docker on the dev box, so built with PODMAN + qemu-user-static (both present) as a `--manifest` list across linux/amd64,linux/arm64. arm64 built under emulation; npm ci for the native addons (rocksdb, sodium) succeeded emulated. This is the reusable recipe for future host images (recorded in host/Dockerfile's header). A GitHub Actions buildx workflow (native arm64 runners) is the eventual cleaner path but was NOT needed to unblock now. - VERSION 0.1.0 -> 0.2.0 (Tim's call): a new tag reflecting all the source-picker + M3 + dashboard work since the 0.1.0 publish, leaving the old digest intact in history rather than overwriting a published tag. Compose pins the manifest-LIST digest (sha256:b8816c25...), an OCI image index, so an amd64 Umbrel and an arm64 Pi both resolve the right arch from one pin. - DEPLOY METHOD: recreated the Umbrel container from the real image (not another docker cp), keeping the old container as peartune-host-old for one-command rollback. Preserving the /data mount kept the host identity (same hostKey) so no phone had to re-pair - the whole point of being careful about the swap. The box's run config (env, /music:ro mount, network_mode host) was read off the live container and reproduced exactly. - REVOKE GATE re-run on 0.2.0 rather than trusting that "packaging can't break it": revoke killed the live connection and the reconnect was denied within a second (gate:deny device-revoked ~0.86s after revoke), buffered track played on. Re-admit needed the tombstone deleted first (fail-closed, as designed) + a fresh pair; drove the app's paste-a-link path to do it headlessly (no QR/camera), same deviceKey came back. Confirms the load-bearing security invariant holds on the shipped image. Verify: npm run verify green (289 tests + builds) before build; hardware smoke on the Umbrel + TCL after. Rollback: `docker stop peartune-host && docker rename peartune-host-old peartune-host && docker start peartune-host`. ## 2026-07-18 - Custom cold-launch splash (was the grey Expo default) Tier: T1 (Android splash assets + config; no wire/host/JS change). Branch: feature/custom-splash (PR #57). Context: Tim noticed the cold-launch screen was Expo's stock grey concentric-circles-on-grid placeholder on a WHITE background. Root cause: there was NO splash config in app.json at all, so a past `expo prebuild` generated Expo's default splashscreen_logo + a white windowSplashScreenBackground. Fix (mirrors how the app icon was done - direct committed assets + config as source of truth): - Regenerated all 5 density splashscreen_logo.png from the pear mark, REUSING assets/adaptive- foreground.svg (the launcher adaptive foreground at 52%). 52% is deliberate: Android 12's SplashScreen API circle-masks the icon (~192dp visible of the 288dp canvas), and 52% sits inside that so the leaf/stem/disc don't clip. Same asset as the launcher icon = one source of truth. - splashscreen_background #FFFFFF -> #17140f (warm graphite) in colors.xml, so it flows into the dark app instead of flashing white. - Added the expo-splash-screen plugin to app.json (image=./assets/adaptive-icon.png, imageWidth=288, backgroundColor=#17140f, resizeMode=contain). This is NECESSARY, not decorative: without a splash config the next prebuild (e.g. the M4 image work) would regenerate the grey default again. Now the committed assets and the config agree. Android-only (no iOS build). ANIMATING the logo's spectrum bars during load was considered and DECLINED (Tim): Android 12 splash animation is time-capped (~1s), OEM/launcher-inconsistent, and only covers the ~1-2s cold-launch window - not worth the flipbook/AVD machinery for that payoff. Verify: 289 tests + builds green (asset-only). ON DEVICE (TCL + Pixel, debug APK): cold launch shows the pear on warm graphite, no grey placeholder, no white flash. ## 2026-07-18 - Retro follow-up: a docked "Playlist" window under the classic player Tier: T1 (client UI only; no wire/host/persisted-shape change). Branch: feature/retro-playlist-window (PR #56). Delivers one of the two deferred PR-#54 retro follow-ups. Context: Tim picked the retro-skin follow-up and asked to weigh the options first. The other candidate - a REAL audio-reactive spectrum - was rejected for now because the only path (Android's Visualizer on ExoPlayer's session) REQUIRES the RECORD_AUDIO permission, which is a credibility hit for a privacy-first, self-hosted music app (a "record audio / microphone" prompt to make bars wiggle) plus a Play-Store review flag, and it grows the already-fragile Android-only expo-audio Kotlin patch - a lot of cost for a cosmetic gain over the existing convincing simulation. The playlist window is the opposite profile: pure WebView/React, no permission, no native, no patch. So we built it and DEFERRED the real spectrum (revisit only as opt-in / default-OFF behind a permission gate, if ever). What it is: a second metal window docked UNDER the classic player (RetroPlayer), faithful to Winamp's separate PL editor. Its green-LCD list is the SAME up-next queue the Queue tab shows - now loaded whenever the classic player is expanded (the queue-load effect gained a `retroPlaylistOpen = expanded && skin==='classic' && now` condition), not only on the Queue tab. Numbered rows, durations right-aligned, the current track on Winamp's selection bar (reusing the retroq green-LCD palette), played rows dimmed. Tap a row to jump (reuses the existing jumpTo, threaded RetroPlayer <- Player <- App as items/index/ onJump). Auto-scrolls the current row into view (scrollIntoView block:'nearest', so only the list moves). Deliberate scope: it is a COMPACT JUKEBOX list - tap-to-jump only. Reorder / remove / clear stay on the Queue tab, which already has the full retro "Playlist Editor" (drag, edit, trash). Duplicating the editor inside the tiny docked window would fight the space and the tap targets. Height/layout: the list has its own max-height:40vh + overflow scroll (overscroll-behavior:contain so it doesn't scroll the page), which is what actually bounds the player height; the .retroplayer grow keyframe end-cap was raised 440px -> 720px so the docked window is not clipped by the open animation. The main window stays fixed while the list scrolls. Verify: 289 tests + all builds green (UI-only; no test surface for the player face). ON THE TCL (debug APK, classic skin, a real 100-track queue via "Play here"): the PLAYLIST window renders under the player ("100 TRACKS", green LCD rows), the current track sits on the selection bar, TAP-TO-JUMP moved playback to the tapped row and dimmed the now-played rows, long titles ellipsize, and the list scrolls internally while the player window stays put. Also installed to the Pixel. STILL DEFERRED (retro): the real audio-reactive spectrum (permission cost above); the home-screen Winamp WIDGET (separate RemoteViews project). ## 2026-07-18 - App icon redesign: a Winamp-flavoured pear, centred by CROPPING TO THE BBOX Tier: T1 (assets + a dashboard-UI recolour/logo; no wire/host-logic/persisted-shape change). Branch: feature/app-icon-redesign (PR #55). Context: Tim picked "app icon redesign" off the design-refresh list. The old mark (a green vinyl + tonearm + node) was off-palette after the analog-amber rework, AND it rendered OFF-CENTRE on his Pixel. Direction: I first rendered a hand-built 3-direction studio (amber-evolution of the old mark / a lit-VU meter / a "pear-record"); Tim steered toward "more Winamp", so I wrote Recraft prompts (green-LCD spectrum + metal chrome, trademark-safe - no Nullsoft lightning/llama) and he generated 7. Chosen: icon3 - a PEAR whose belly is a chrome-bezelled green-LCD spectrum analyzer. It keeps brand ownership (the app name IS the mark; unmistakably PearTune, not a generic player) while carrying the retro-skin's green LCD, so the launcher icon and the classic in-app skin rhyme. The pure-Winamp options (a player window, a spectrum-in-a-frame) were rejected as too generic for a launcher icon. The load-bearing fix - CENTRING BY BBOX, not a hardcoded transform. The OLD master centred the mark with a baked translate/scale tuned to a specific Recraft export; when the artwork changed, the mark sat off-centre (what Tim saw on the Pixel). The new pipeline instead measures the mark's true bounding box (inkscape --query on the amber pear path, which encloses everything), drops Recraft's own landscape background path, and places the bbox in a square tile via a nested with preserveAspectRatio - so the mark is ALWAYS exactly centred regardless of the source art. Verified numerically (rendered the 52% foreground, trimmed: margins L=R=345, T=B=245, zero diff on both axes) and visually against the Android 72dp-viewport / 66dp-safe-zone rings. Mark fills 64% of the master (inside Android's 66% adaptive crop), 52% on the adaptive foreground (launcher zoom/mask headroom). The two documented traps are honoured: iOS PNGs have the alpha channel stripped (Apple rejects alpha), the Android foreground is padded to 52%. Derived-asset set unchanged in shape: assets/icon.svg (master, opaque #17140f), adaptive-foreground.svg + adaptive-icon.png, icon.png/icon-1024.png (iOS), icon-512.png (Play), umbrel/icon.svg, all android mipmaps, and the icon background moved #181419 -> #17140f (colors.xml iconBackground + app.json). Nested- cropping (over a hand-computed matrix) also keeps Recraft's userSpaceOnUse gradients consistent - they resolve inside the inner viewport, so they scale/position with the mark. Bundled two dashboard tweaks (same visual-refresh intent, same PR): - The connected/online `.live` dot is GREEN again. It had gone amber because it reads --primary; the palette rework left green only as the semantic --good. Repointed .live to --good and recoloured --glow (the pulse ring) to match in all three theme blocks. Safe because --glow is used ONLY by this dot - the radial background glow is a separate token - so nothing else changed. This restores traffic-light semantics: online=green, revoked=amber(warn), offline=grey. - The dashboard's top-left brand logo is now the app icon, embedded as a 128px PNG data URI in a new host/ui/app/icon.js (NOT the 34KB Recraft SVG, and NOT an external file - dashboard.html must stay one self-contained string). .brand-mark became an ; the MusicNotes import stays (still used by the Music-source panel h2). Verify: 289 tests + all builds green (icon assets have no test surface; the dashboard-is-a-built-artifact test still passes). ON DEVICE: built + installed the debug APK to the TCL (new icon centred on the launcher) and the Pixel; deployed the dashboard to the Umbrel (docker cp + restart, since server.js readFileSync's dashboard.html at boot) and verified live - green dot beside "1 online", new pear logo top-left. ## 2026-07-17 - A "classic" player skin: the WebView UI makes a retro amplifier face cheap Tier: T1 (client UI only; a new persisted setting `skin`, no wire/host change). Branch: feature/retro-skin. Context: Tim asked "is a classic Winamp skin possible?". The load-bearing finding: PearTune's UI is a WEBVIEW (arbitrary HTML/CSS/canvas), so a pixel-faithful vintage-amplifier player is cheap and fully in our control - unlike the HOME-SCREEN WIDGET version, which is boxed in by Android's RemoteViews (no canvas; a faithful look needs pre-rendered bitmap sprites, and live spectrum bars fight the widget update throttle). So the two are separable, and the in-app skin is the high-value one. Built a browser POC first (shown to Tim), then shipped it. What it is: a `skin` preference (modern | classic) beside theme/density in the worklet settings.json. Classic re-faces ONLY the EXPANDED player - a metal-chrome window with a green LCD time, a scrolling title marquee, a live spectrum, and beveled transport. The mini bar and the rest of the app stay on the amber theme, so collapsing always lands somewhere familiar. The RetroPlayer reads the SAME now/status and drives the SAME controls (call('toggle'|'prev'|'next'|'seekTo'), onShuffle/onRepeat/ onStop) - it is purely presentational, no new playback surface. Deliberate choices: - The skin is theme-INDEPENDENT (fixed metal + green LCD hexes, not var() tokens): a skin is its own world, and the iconic green LCD is the point. It sits inside the amber app unapologetically. - ORIGINAL look inspired by the classic player - PearTune-branded ("PEARTUNE" titlebar), no Nullsoft artwork/bitmaps (the trademark/asset line flagged in TODO). - The spectrum is SIMULATED (bass-heavy jitter + falling peak caps, frozen/decaying when paused, static under prefers-reduced-motion). A REAL audio-reactive spectrum needs a native hook (Android's Visualizer on ExoPlayer's session, streamed over the shell->WebView bridge) - deferred, noted; most skins were simulated anyway. - The marquee renders the title TWICE back-to-back with a -50% scroll, so the loop is seamless and the title is always on screen (a single copy left the LCD blank half the time - caught on hardware). - It is a distinct tree from the modern expando, so the grow/shrink tween does not carry across the swap - acceptable for a skin the user deliberately switches to. The MINI bar got its own classic "windowshade" face (Tim asked): when classic + collapsed, the player is a thin metal strip - tiny green LCD time, scrolling title, a mini spectrum, play/pause, a hairline progress line - tapping it expands to the full window. So the retro illusion holds in both states instead of collapsing back to the modern amber bar. The expanded window FILLS the sheet edge-to-edge (Tim asked): it was a rounded metal box floating in the amber dock (amber caret strip above, amber padding around) - a "window in a window". Now the metal chrome is full-bleed and square-cornered, the collapse (▾) + close (×) sit in the titlebar where Winamp's minimize/close were, and the dock goes metal-dark under it (.dock-retro, toggled from App - not :has(), for older-WebView safety) so nothing amber peeks at the seams. Reads as a deliberate retro takeover, not a contained widget. The SHADE strip got the same full-bleed treatment (Tim asked): it is edge-to-edge and square too, and .dock-retro applies whenever the classic player is showing (mini OR expanded), so the dock is metal-dark under the strip as well - not just the full window. (The strip's padding override needs .player.mini.retromini specificity, since the base .player.mini padding would otherwise win.) Switching back to Modern / stopping restores the amber dock. The QUEUE tab became a green "Playlist Editor" when classic is on (Tim asked how far to reskin beyond the player; chose Queue over the album grid / settings). This is the one non-player surface with a real Winamp counterpart - its playlist window - and it is a LIST, so green-on-black reads fine where a cover-art grid would fight the art. Deliberately CSS-forward: a `retroq` class restyles the existing QueueScreen (metal titlebar, green numbered rows via a CSS COUNTER so no markup change, the current row on Winamp's selection bar, played rows dimmed, times right-aligned) and EVERY interaction is unchanged - tap-to-jump, edit, drag-reorder, remove, clear. The album/artist grid, Settings, About, and the dashboard stay amber on purpose: no Winamp analog, and the green chrome would clash with cover art + cramp tap targets (the opposite of why the amber-neutral chrome exists). Deferred follow-ups (not built): the real Visualizer spectrum; a playlist window docked INSIDE the player; the home-screen Winamp WIDGET (its own bitmap-sprite + media-session project, separate surface). Verify: 289 tests (UI-only; no test surface for the player face) + builds. On the TCL: switched Settings -> Appearance -> Player skin -> Classic, expanded the player - LCD ticks, the title marquee scrolls and stays visible, the spectrum bounces while playing and freezes when paused, and prev/play/ next/seek/shuffle/repeat all drive the real player. ## 2026-07-17 - Palette rework: "analog amber" replaces the spruce-green, across phone + dashboard Tier: T1 (pure token values; no wire/host/persisted-shape change - the [data-theme] contract and every var() are unchanged, only their values). Branch: feature/theme-analog-amber. Context: Tim picked "colors/themes" off the design-refresh list and chose (from a 3-direction visual studio rendered on a real PearTune screen) direction 2, "analog amber", over "spruce refined" (evolution) and "ink & signal" (neutral art-forward). The choice, and why it fits the subject: a lit-VU amber-gold (#e6b24e dark) on warm graphite, the palette of a vintage amplifier. It keeps the deliberate WARM, LOW-GLARE dark the app already committed to (an app you use with the screen off half the time) while giving the chrome a neutral graphite so the ALBUM ART carries the colour on every screen - amber is reserved as the "signal" (play, active tab, the heart you set, progress). It also sets up the deferred classic-Winamp skin. Rejected: staying spruce (too close to a no-op) and cool-neutral "ink & signal" (drops the intentional warmth for slightly more night glare). This is NOT one of the generic AI-design defaults (cream+serif+terracotta / near-black+acid-green / broadsheet) - it is grounded in the music-hardware subject. Light mode: amber has to DARKEN to a functional bronze (#9c6a12) to clear ~4.5:1 as both button background (white label) and accent text on cream - a light gold would fail contrast. So light reads as warm bronze-on-cream; dark is where the amber glows. Dark is the default and the common case (system-following), so this asymmetry is acceptable and coherent. Scope - TWO token systems + strays, all moved together so nothing is left green: - Phone: src/ui/styles.css [data-theme] blocks (--color-* set). Plus two hardcoded DARK-overlay colours that can't be var() because they sit over always-dark surfaces (the QR scanner text over the camera feed; the full-screen art-viewer caption over black) - moved to the new dark tokens. - Shell strip: app/index.tsx SHELL_BG (the status-bar + WebView backing) must equal --color-surface-base or a cold-start flashes the old colour; updated to #17140f / #faf6ee. - Dashboard: host/ui/app/styles.css uses its OWN shorter token names (--bg/--primary/--accent/ --glow/--good/warn/bad) with THREE colour blocks (dark, @media prefers-light, explicit [data-theme=light]) - all three updated, and the radial --bg-accent glow + --glow were recoloured from the green rgba to amber. - Login page: host/ui/login.js is a separate hand-written dark-only string with its own hardcoded palette (bg/fg/accent/danger + the on-primary button text + the error-box rgba) - updated too. Semantic colours kept distinct from the new amber primary: error stays a warm red (#e0705f dark / #c1362b light), success a muted green (#8bb877 / #3f7d3a) - traffic-light semantics survive a brand recolour; warn is a hotter amber (#f2b544 / #8a5a10), used rarely and never beside the primary. Verify: 289 tests (unchanged - values only, no logic; the dashboard-is-a-built-artifact test still passes) + all builds. ON THE TCL (debug APK) + the Umbrel dashboard (docker cp'd dashboard.html + login.js): dark reads warm-graphite with amber Play/active-tab/hearts/progress and album art carrying the colour; light reads cream with the bronze accent; the theme toggle (Dark/Light/System) switches cleanly both ways. Reset the test phone back to System afterward. ## 2026-07-17 - Session handoff v1 follow-ups: an instant-presence PUSH, position in the session row, honest paused wording Tier: T2 (a new server->client wire message on peartune/media/1 + two new persisted fields on the session row). Additive and backward-compatible - an old peer never registers the push type and drops the frame, an old host never sends it, both fall back to today's lazy presence. Branch: feature/handoff-instant-presence. Delivers the three deferred follow-ups from the 2026-07-17 session-handoff proposal at once, because they share one piece of infrastructure. THE KEYSTONE - a server->client push. The media channel was pure request/response (req/res/chunk/ end/err). Instant presence needs the host to speak first: when device B claims the token, the device that HELD it (A) must be told NOW, not on its next ~4s heartbeat. So `push` is appended as message type 5 (host/presence.js + protocol/framing.js `push` + protocol/channels.js). A `Presence` registry maps deviceKey -> live media channel(s); serveMedia registers on channel open and unregisters on close; session.claim reads the PRIOR activeDeviceKey and `presence.notify`s it 'session-superseded'. The client (client/index.js onPush -> src/bare.js) drops the token and emits `session:superseded` to the shell, which runs the SAME onHandedOff as the lazy path (idempotent, so a lazy rejection landing too is harmless). Why this does NOT compromise the design (Tim asked for the assessment before approving): the push adds NO access surface. It only rides a channel that already exists, which only exists on a connection the firewall already admitted; register() runs AFTER the grant check. Revoke destroys the connection, whose close unregisters here, so a revoked device is gone from the registry and cannot be pushed to - the push is strictly WEAKER than the connection it rides and never touches the firewall, the grant store, or the revoke path. The only actor that gains a capability is the host, already fully trusted. Both load-bearing rules (grant store host-local; revoke kills live connections) are untouched, which is why this stays T2, not T3. POSITION rides the SESSION ROW now, not the resume row (Option A, chosen over the proposal's resolved #3). The active device carries positionMs + playing in the SAME ~4s heartbeat that already mirrors the queue (host/state.js setSession/claimSession), so "Play here" seeks from the claim reply ATOMICALLY - no extra round-trip, no read-after-write race against resume. The proposal's #3 (the LOSING device flushes an exact resume on handoff) was rejected because its exactness depends on the loser being alive to flush, and the most common handoff (walk away from a backgrounded/paused phone) is exactly when it CANNOT - so it would silently degrade to the <=8s resume, i.e. least reliable when handoff is most common. Option A bends the proposal's tidy "position rides resume" separation for robustness; the cost is only conceptual (position now lives in two rows), not write traffic (the heartbeat fired anyway). Exactness at the handoff MOMENT comes from the shell forcing a snapshot on pause (app/index.tsx toggle -> persistQueue(true)): a pause otherwise wouldn't push (the status poll goes silent the instant playback stops), so without it another device's card would keep saying "Playing" and a takeover would seek stale. PAUSED WORDING (follow-up #2): the session row's `playing` flows to the card, which now reads "Paused on X" vs "Playing on X" honestly (src/ui/App.jsx HandoffCard, worklet sessionInfo activePlaying). The card refreshes on connect/resume/handoff, not live - the verb is correct at load time, a known v1 limit (a backgrounded device's card can lag the other device's play/pause until it next foregrounds). THE HONEST LIMIT, observed on hardware: the push only reaches a loser whose worklet is CONNECTED. A device playing from its buffer/cache with the screen off has its worklet suspended by Android and its host connection dropped (its heartbeats stop) - so `session:claim` logs `superseded:0`, nothing is pushed, and it falls back to lazy presence (stops when it next reconnects). This is the OS-suspend limit flagged in the assessment, not a bug: the push tightens the alive-but-not-pushing window (paused-connected, or the <=4s heartbeat gap) from lazy to instant, and it cannot wake a suspended process. The two-phone test caught BOTH cases cleanly (below). Verify: 289 tests (test/presence.test.js registry semantics incl. multi-connection + isolation + throwing-sender; state.test.js positionMs/playing store + clamp + claim-adoption; two new integration.test.js end-to-end-over-real-DHT tests - a claim pushes 'session-superseded' to the prior holder carrying the winning generation + exact positionMs in the claim reply, and a self re-claim pushes nobody). ON TWO PHONES (TCL + Pixel, host deployed to the Umbrel via docker cp, new APK on both): the card flipped "Paused on Pixel" -> "Playing on Pixel" with the Pixel's real state; BOTH handoff directions logged `session:claim superseded:1` and the loser's worklet logged `session:superseded {generation:N}` ~230-460 ms after the host claim (effectively instant, vs the old ~4s), tearing down / pausing playback at once; the taking-over device landed at ~88 s against the source's ~84 s (the <=4s heartbeat freshness + spin-up, position transferring correctly); single active player held throughout. The suspend fallback was also observed (`superseded:0` when the loser had been playing from buffer screen-off, lazy takeover still correct). ## 2026-07-17 - Library sort now PERSISTS across relaunch (closing the v1 gap), and a stale closure was in the way Tier: T1 (client only; no wire change, no host change - the sort choice rides the existing worklet settings.json alongside density). Branch: feature/sort-persistence. Context: the 2026-07-16 sorting entry shipped sort as per-view and IN-MEMORY, "resets on relaunch (density persists, sort does not - acceptable v1)". This closes that gap: the per-view sort {albums,artists,songs}:{key,order} is written to settings.json (setSettings, same path density uses) and restored on launch. No worklet change - saveSettings already merges any patch key and init already returns loadSettings(), so `sort` round-trips for free. The subtlety, and it is the whole reason this was more than a one-liner: restoring the sort into STATE was not enough. The folder host connects in the BACKGROUND, so init returns connected:false and the first library load fires from the once-registered `host:connected` listener - whose closure captured the FIRST render's EMPTY sort (the same stale-closure trap the nowRef/statusRef/ liveRef pattern already exists to dodge). Result on hardware: the Display sheet correctly showed the restored Year sort, but the album list loaded in DEFAULT order. Fix: read sort through a `sortRef` so any no-param loader sees the live value, PLUS pass explicit params on the connected-at-init branch (setSort has not committed in that same tick - the reason applySort already threads params directly). A pure `sortParamsFor(sortMap, view)` computes the params off a not-yet-committed map, shared by the restore load, applySort's optimistic reload, and sortParams. A cross-source note (benign): the sort map is global, not per-source. A songs sort saved on a folder source, then a switch to Subsonic (which cannot sort songs and hides the control), leaves the stored key inert - the adapter ignores an unknown/unhonored sort (falls through to default order, per the 2026-07-16 entry) and the hidden control means the user cannot see or change it until they switch back. Acceptable; matches how density is also global. Verify: 266 tests (unchanged - the mechanism is a settings round-trip + a ref read; the existing sort.test.js still pins the comparators/capabilities). ON DEVICE (TCL + Umbrel/folder): set Albums=Year asc and Songs=Title desc, force-stop -> relaunch each time; both the loaded list order AND the Display sheet (including the desc flag) came back exactly. First relaunch caught the stale-closure bug (list default while sheet showed Year); after the ref fix, the cold launch loaded year-sorted. Reset both back to Default afterward so the test phone is not left changed. ## 2026-07-16 - Library sorting is a SERVER-SIDE sort param, honestly advertised per source Tier: T1 (adapter + UI; no wire change - `library.list` already passes params straight to the adapter, so `sort`/`order` are additive and an old client that never sends them is unaffected). Branch: feature/library-sorting. Context: the Songs view had only the server's order (roughly artist/album/track); TODO flagged sort-by-title/date/artist as needing "its own paged-rows cache or a host-side sort param". Tim picked the approach after being reminded FOLDER is the primary user-case ([[Folder is the product]] 2026-07-14). The decision that folder-primary drives: sort SERVER-SIDE, not with a client cache. A folder holds the whole library in memory, so it sorts any field for free; a client-side full-library cache would be complexity aimed only at Subsonic's limitation, which the target users do not hit. So each adapter advertises what it can actually do in `stats().sorts` (`{tracks|albums|artists: {keys:[...], reversible}}`) and the client shows ONLY a control the source can honor - no dead buttons. - FOLDER (primary): full set, both directions. Sorts copies of the in-memory _sorted* arrays by a canonical comparator (host/adapters/sort.js), memoized per (type|sort|order) and cleared on rescan - so scrolling a title-sorted list does not re-sort the library per page (the perf note the file already carried). - JELLYFIN: full set, server-side via SortBy/SortOrder. Default track order (AlbumArtist, Album,disc,track) is preserved when no sort is sent. - SUBSONIC (Tim's Navidrome): albums only (getAlbumList2 types name/artist/year; byYear needs a 0..9999 range). SONGS cannot sort - the all-songs list is an empty-query search3, which takes no order param - so tracks.keys is [] and the client HIDES the Songs sort control on a Subsonic source. Ascending only (the API has no order flag), so albums.reversible is false. Artists come alphabetical already, so [] (offering "name" would just reproduce the default). The contract is canonical keys (title/artist/album/year/duration; name; name) that each adapter maps to its own server's language, tie-breaking toward shelf order so a year- or duration-sort stays stable within equal values. UI: a pill in the pickrow (a compact native ("Default order") overflowed the pickrow on Albums/Artists - shortened the default label to "Default", capped the select width, and gave the segmented picker min-width:0 so it yields space; the row now fits with no clipping. The folder FULL set (Songs title sort + asc/desc toggle) was not exercised live - it needs switching Tim's active source, which orphans listening state - so it rests on the unit tests. NOTE: sort choice is per-view and in-memory; it resets on relaunch (density persists, sort does not - acceptable v1). ## 2026-07-16 - Guest grants: time-limited access, enforced at connect AND by a live sweep Tier: T2. Proposal: proposals/2026-07-16-guest-grants.md. MVP scope (Tim): time-limited only (library-subset `paths` deferred), created via a guest pairing window with a duration. Context: the grant store always reserved `expiresAt`/`paths` as nulls for "v2 guest grants", and `gate.decide()` already denied an expired grant at connect. So the groundwork existed; this is the value change it was designed for. What's new, and it is small: (1) `startPairing({expiresMs})` opens a GUEST window; the duration is OPERATOR-SET, host-side, never read from the device's hello (a guest must not pick its own expiry - the same rule as "a device may name itself but not set its own personId"). (2) pairing stamps `expiresAt = now + expiresMs` (`grants.grant` gained the param; re-pairing an already- granted device through a guest window REFRESHES it via a new `grants.setExpiry`, so "extend the pass" = "scan again"). (3) a 30s periodic SWEEP (`_sweepExpired` + the pure `gate.sweepKills`) cuts any live connection whose grant `decide()` now refuses. The load-bearing point, and why (3) is not optional: `decide()` runs only at CONNECT. A guest that connected before expiry would keep streaming until it happened to reconnect - the exact gap `Connections` exists to close for revoke. Revoke fires an event we hang `kill()` on; a time-based expiry has none, so the host sweeps. 30s lag is fine here (a SCHEDULED expiry, not the sub-second claw-back a lost-phone revoke needs; revoke keeps its instant, event-driven kill). The dashboard: a "Guest pass" toggle + duration picker (24h/7d/30d) in the pair modal; device rows show a calm "guest" badge + "expires in 3h" countdown, and an amber "expired" once past. The phone needs NO change: an expired guest experiences exactly what a revoked one does. Deferred (follow-ups, in the proposal): library-subset `paths`; editing/PROMOTING expiry on an existing device from the dashboard (so for now a guest is made permanent only by revoke+delete+ re-pair through a full window); a client-facing "expires in X" banner. Verify: 248 tests (sweepKills per-branch incl. fail-closed, grant expiresAt/setExpiry, the already-tested decide() expiry branch). On the TCL + Umbrel (host deployed via docker cp): a 2-minute guest window, TCL paired guest -> host log `pair:already-granted {guest:true}` then `host:expired {killed:1}` ~27s past expiry (within the 30s bound) then `gate:deny {grant-expired}` on its reconnect; the phone showed "Not connected", cached audio kept playing under the lease, and a normal re-pair restored a permanent grant. ## 2026-07-16 - The You picker is icon-first: the active view shows its label, the rest are icons Tier: T1 (UI only). Branch: fix/you-picker-icon-pills. Context: the You sub-picker grew to four pills (Favorites / Most Played / Playlists / Downloads) and, on a full-featured host, overflowed the row - the `.seg.scroll` clipped "Downloads" and made you scroll a segmented control sideways (Tim reported it). First cut (rejected): move Downloads to a download-icon button in the You HEADER. It fixed the crowding but the header icon offset the centered "You" title - Tim did not want the title pulled off-centre. Choice: keep all four as peers, but make the picker ICON-FIRST (`.seg.icons`). Every option is an icon; the ACTIVE one also shows its label and grows to fill (flex:1), the others collapse to just their icon (flex:0 0 auto). Four options now fit one row with no scroll, and the header stays centered. Icons: Heart / ChartLineUp / Playlist / DownloadSimple; each button carries an aria-label so the collapsed icons stay accessible. The active fills to the same edge whichever it is, so nothing offsets the page header. Verified on the TCL: all four fit one row; tapping each expands it to icon+label and collapses the rest; the "You" title stays centered. ## 2026-07-16 - Downloaded-album covers are cached on disk and served DISK-FIRST, not as a fallback Tier: T1 (client-only; new on-disk store + one shim read path; no wire change, lease semantics unchanged). Branch: feature/offline-album-art. Context: milestone-3 phase 5C made a downloaded album PLAY offline (audio written through to disk), but its COVER still came from a live fetch - so an offline Downloads list showed placeholders. The last visible loose end of the offline story. Choice: persist each downloaded album's cover bytes to disk (worklet/art-cache.js, an ArtStore keyed by coverId - small, no LRU, bounded by the pinned-album list). pinAlbum fetches the DISTINCT coverIds (album + tracks; usually one) best-effort at download time - a cover that fails to fetch never fails the download. The shim serves this store, LEASE-GATED exactly like cached audio (a revoked/long-offline device's covers go dark with its audio). The subtlety, caught on hardware: serve DISK-FIRST, not as a catch-block fallback after the live fetch. The first cut fell back to disk only in serveArt's catch - but offline that catch is reached only AFTER `await ensure()` blocks on a connect timeout, so the cover loaded minutes late or never (verified: placeholder + no art:hit, then art:hit once disk-first landed). Disk is checked BEFORE ensure(), so offline it serves instantly. Scoped to ONE size (DEFAULT_ART_SIZE, the size the Downloads views request): only that size hits disk, so the online library grid (120/350/500) still fetches exact-size art, and there is no quality regression while browsing. Store size and serve size share the exported constant so they cannot drift. A full-screen 1200 viewer offline still shows the placeholder - acceptable v1 (Downloads shows the cover, which was the ask). unpinAlbum frees the covers unless another pin still shows them; UNPAIR (purgeAll) clears the whole store. Verify: 239 tests (9 for ArtStore, incl. persistence + slash-in-coverId safety). On the TCL: downloaded 2020 AD, wifi OFF, force-stop, cold launch - the Downloads list AND the detail header showed the real cover (art:hit served from disk), where before both were placeholders. ## 2026-07-16 - Queue reorder/remove uses ExoPlayer's own move/remove, and the patch got cleaned Tier: T2 (native expo-audio patch surface + a UI edit mode; no wire change). Branch: feature/queue-reorder-remove. Context: the queue became a persistent, first-class tab (PR #29), so "see it but can't edit it" was the obvious gap. TODO flagged it needing "the same kind of patch as addQueueSources". Choice: two new Functions on the expo-audio patch - moveQueueItem(from,to) and removeQueueItem(index) - wrapping ExoPlayer's OWN moveMediaItem / removeMediaItem. NOT a rebuild via setQueueSources: re-handing the whole playlist would restart the current track and break gapless, which is the exact trap the addQueueSources comment already warns about. The shell mirrors the move/remove in queueRef and fixes indexRef with a pure, unit-tested helper (app/queue-index.js, cross-checked against a brute-force array move for every position). Three subtleties, each a bug caught in build/test/hardware: - currentMediaItemIndex SHIFTS WITHOUT a transition. Moving/removing an item across the current one changes its index but not which track it is, so ExoPlayer fires no onMediaItemTransition and the JS `currentQueueIndex` mirror would go stale. The native Functions re-sync it (= ref.currentMediaItemIndex) on the main thread, where reading the player is safe. - Removing the CURRENT track needs an explicit announce(). ExoPlayer slides the next track into the same slot, so the index is UNCHANGED - the status listener's "index changed?" check never fires, and the mini-player/lock-screen would keep showing the REMOVED track. The shell now announces the new current explicitly on a current-track removal (verified on hardware: removed the playing track, mini-player advanced AND playback continued - no audio-focus loss, because there is no seek here, unlike playIndex's documented announce trap). - The navbar badge reads status.queueLength, which only refreshes on a play-status tick - so a remove while PAUSED left it stale. The UI now updates it from the removed-queue length at once, the same fix the play:queued handler uses. Also: patch-package REGENERATED the patch SOURCE-ONLY (--exclude 'android/build/'), dropping 479KB of committed build-output artifacts (763 of the old patch's 767 file-diffs were dex/class noise; the real content is 3 .kt + expo-module.config.json). The source-build-forcing is the `publication` block removed from expo-module.config.json - kept. This directly pays down the "the patch is fragile" rot-risk. Re-verified: gradle built from source and the dex contains moveQueueItem + setQueueSources (media3 has neither), proving the Kotlin compiled, not a stale AAR. Verify: 220 tests; on the TCL, reorder (highlight follows the track), remove non-current (badge drops), remove current paused/playing (advances, mini-player updates, keeps playing), and force-stop -> relaunch restored the edited queue exactly. ## 2026-07-16 - A starved player must stop on IDLE, not only on a buffering-stall Tier: T1 (client behavior; refines the 2026-07-14 graceful-reconnect T3 decision, no wire or host change). Branch: fix/revoke-starve-clean-stop. Context: graceful-reconnect (2026-07-14) made "a drop is not a stop" - the shell keeps the buffer playing on host:disconnected and lets the player's fate decide: a switch reconnects, a revoke starves. The starve was caught by ONE watchdog: dropped && isBuffering with the position frozen past a 15s grace. That path was flagged as "never observed firing", and on inspection it was worse than untested - it was broken for the case it exists to handle. Finding (code + hardware): when a starved track cannot get bytes over a dead/revoked connection, ExoPlayer exhausts its bounded retries and falls back to STATE_IDLE. expo-audio registers NO onPlayerError listener, so a fatal error reaches JS ONLY as a single `playbackState:'idle'` status update, with isBuffering=false. The old watchdog therefore (a) never caught the idle terminal state (it keys on isBuffering, and RESETS its window on a non-buffering update), and (b) could not rely on its 15s timeout either, because the periodic status poll that would re-check the window is gated on `if(playing)` in expo-audio's AudioPlayer.kt and goes silent the instant the player stops playing. Net: the player froze on "buffering…" indefinitely instead of stopping. Choice: extract the decision into a pure, unit-tested `decideStarve()` (app/starve.js, test/starve.test.js - branch-per-test, the same discipline as host/gate.js's decide()) and add a TERMINAL branch: `dropped && playbackState==='idle' -> starved`. The buffering-stall timeout stays as a backstop for a source that hangs in BUFFERING rather than erroring to idle. onStarved still fires play:lost + stop() (clear player, wipe queue), so a starve ends cleanly. Why this is still just T1: revoke's guarantee is UNCHANGED - the host already cuts all NEW access at connect time (killed live conn + "host refused" on reconnect, both re-confirmed on hardware); this only fixes what the CLIENT does when its own buffer runs dry, turning a frozen player into a clean stop. It does not let a revoked device reach anything new. Verify: 220 tests green. On the TCL: revoked mid-play, skipped to an uncached track over the dead connection; the MediaSession was destroyed the instant ExoPlayer's retries exhausted (coincident with the last shim:stream-failed), NOT 15s earlier at the stall-timeout - proving the idle branch is what stopped it. The player cleared cleanly (mini-player + queue gone). Aside confirmed while testing: re-pairing a revoked device requires deleting its host tombstone first (/api/device/delete) - a revoked grant is denied even inside a pairing window, which is the fail-closed model working (DECISIONS 2026-07-14 dashboard-auth / grants). ## 2026-07-15 - The dashboard is a BUILT React app, not a hand-written HTML string Tier: T1 (UI rewrite; NO wire change, NO persisted-shape change, NO auth-model change - the same HTTP API, served differently). The control plane is security-relevant, so the invariants that made the old page safe are pinned, below. Context: host/ui/page.js was ONE 700-line hand-written HTML template literal that was the operator control plane (pairing QR, revoke, per-person grants, source picker). It had already produced, from its shape alone, a stored XSS (device labels concatenated into innerHTML) and two syntax-in-a-string bugs invisible to require() (a backtick in a comment, a duplicate const). The TODO named the rewrite a milestone: "an app, not a dashboard." Tim asked for an app feel, everything on one screen without scrolling, the phone app's look, and a Support Development page like the PearCircle seeder's. Choice: a real React app under host/ui/app/ (main/App/styles/theme/api), bundled by esbuild and INLINED into one self-contained host/ui/dashboard.html (scripts/build-dashboard.mjs, npm run build:dashboard, in the verify gate). Layout is a SINGLE control panel (Tim's pick over tabs): a header with live stats, a pairing panel, a people-first Access panel (persons expand to their devices; unassigned/claimed devices in their own group), a music-source panel (picker + folder browser + dirty-guard), and a Support sheet. It reuses the phone app's exact theme tokens and Manrope font (imports src/ui/fonts.js) so the two read as one product. Why a build-time artifact and not a runtime dep: React/esbuild/qrcode are ROOT devDependencies and NONE enter the host image. The image's Dockerfile copies host/ with no build step, so the built dashboard.html is COMMITTED (as page.js was) and served as one string - host/package.json stays the eleven server packages it is (DECISIONS 2026-07-14 "the host image gets its own package.json"). login.js stays a small hand-written string (still guarded by the template-literal parse test). Security invariants PRESERVED (this is why the tier note matters): - XSS: React escapes every interpolated string by default, so the device label / user claim cannot execute. The class of bug the old page kept producing is gone. A test now asserts dangerouslySetInnerHTML appears NOWHERE in the dashboard source. - The auth gate (host/ui/auth.js), the fail-closed requireSafeBind, and every JSON endpoint are UNCHANGED. The rewrite is client-side only. - The folder browser still renders filesystem names as escaped JSX (was hand-built DOM nodes for the same reason). - The source card's dirty-guard is kept: once the operator touches it, the 3s poll no longer clobbers the in-progress edit. - Clipboard uses an execCommand fallback, because the dashboard is served over a non-secure origin (Umbrel proxy / LAN) where navigator.clipboard does not exist - the same fix the seeder uses. Support page: the seeder's two-rail donation UI (⚡ BTC ⚡ / 💲 USD 💲 tabs, a QR per rail, copy buttons), using PearTune's own suite addresses (Strike Lightning, on-chain BTC, Buy Me a Coffee) - rendered entirely client-side, no phone-home. Verify: 210 tests green (two page.js-string tests rewritten to the new architecture); the built dashboard boots and serves on a live host (GET /, /api/state, /api/pair/start all correct against the fixture library); a headless react-dom/server render of App throws nothing. REMAINING manual check: open the dashboard in a browser and eyeball the single-screen layout + the Support sheet (a screenshot cannot be driven here). Rollback: revert the branch; page.js returns from git history and server.js requires it again. ## 2026-07-15 - Offline revoke is a LEASE, not a purge-on-reconnect (the distinction is unreliable) Tier: T3 (security semantics of revoke vs. offline copies). Proposal: 2026-07-15-offline-pinned-cache (this supersedes its "purge on refused reconnect" mechanism). Context: Phase 5B lets a phone keep downloaded audio for offline playback, which relaxes "revoke stops the music". Tim's chosen bound (AskUserQuestion 2026-07-15) was "persist, but purge the moment a revoked device reconnects and the host refuses it" - keeping the guarantee that a device, once online, loses its downloads. The plan relied on telling a REFUSED connect (host up, firewall denied = revoked) from an UNREACHABLE one (timeout = server merely off), reusing the signal pair() uses. Finding (on hardware): the signal does NOT hold. With the host container STOPPED, the phone's reconnect closed exactly like a firewall refusal - `conn.once('close')` fires for BOTH cases, and the timeout branch almost never wins. So a purge-on-refused-reconnect would DELETE a legitimate user's downloads whenever their server was simply off. That is the exact failure the proposal warned about. Root cause: at the connection layer a revoke and a dead host are indistinguishable (both just close); the client cannot tell them apart without host help. Choice (Tim, 2026-07-15): a client-only LEASE. Every successful connect stamps `lastAuth`; cached/downloaded audio is served from disk only while `now - lastAuth < 14 days`. A revoked device never re-authorizes, so its downloads go dark after the grace; a device whose server is off re-authorizes the instant it is back, so it never loses anything. Files are NOT deleted on expiry - re-pairing (a fresh authorization) makes them playable again. UNPAIR remains a deliberate, reliable purge (it wipes audio + cached state + the lease). The reconnect-refusal purge is removed entirely. Alternatives: (a) a host "you're revoked" signal (admit a revoked device to a tiny channel, tell it, then close) - reliable instant purge, but softens the firewall-denies-at-connect model and is more T3 host surface; deferred, revisit if instant claw-back matters. (b) keep trying to distinguish refused vs. unreachable - rejected, proven unreliable. Consequences: a revoked device may play its existing downloads offline for up to 14 days (bounded, and it can capture the current stream anyway - same reasoning as graceful-reconnect 2026-07-14). "Revoke cuts all NEW access within a second" is UNCHANGED (browse / next uncached track / art / new download denied immediately). The relaxation is only for already-downloaded bytes, now time-boxed instead of promised-instant. ## 2026-07-15 - Emby is a SHIM on the Jellyfin adapter, not a new source kind Tier: T1 (no wire change, no new persisted kind/field, no migration) Context: the compatibility roadmap ranked Emby second (Family 2). Emby is in the Umbrel official store, and Jellyfin forked Emby ~2018, so the roadmap called for "a shim, not a rewrite". Finding: the endpoints are the same (/Users/AuthenticateByName, /Items, /Audio/{id}/ stream?static=true, /Audio/{id}/universal, /Items/{id}/Images/Primary, /System/Info/ Public). The ONLY delta is where auth goes: Jellyfin reads identity+token from `Authorization: MediaBrowser Client=..., Token="..."`; Emby reads identity from `X-Emby-Authorization` and the token from a separate `X-Emby-Token` header. Choice: SEND BOTH header flavors on every request (_authHeaders()). Each server reads the one it knows and ignores the other, so ONE code path serves both with no server-sniffing branch. Emby rides the existing 'jellyfin' kind (the DECISIONS 2026-07-14 Jellyfin entry already anticipated this - "how an eventual Emby adapter will label itself without a new source kind"). The picker button becomes "Jellyfin / Emby"; the app still shows the server's OWN name via ProductName ("Jellyfin" vs "Emby Server"), so nothing is guessed. Why no new kind: a new 'emby' kind would be a T2 (persisted config + trackId re-scoping) for zero benefit - the adapter, endpoints and mapping are identical, and sourceName already distinguishes the two servers to the user. Keeping one kind avoids a migration and matches how 'subsonic' covers many servers (DECISIONS 2026-07-15). Verify: unit tests pin that both header flavors are sent and the token lands in BOTH the Authorization header and X-Emby-Token. Live: probe a real Emby on the Umbrel (browse + stream via the Emby headers), and confirm Jellyfin is unregressed. ## 2026-07-14 - A drop is not a stop; revoke cuts NEW access, not the current buffer Tier: T3. Proposal: proposals/2026-07-14-graceful-reconnect.md (approved by Tim) Context: switching networks (wifi<->cellular) killed the P2P connection, and the shell reacted by calling stop() - tearing the player down AND wiping the queue. A five-second network blip cost you the whole queue. That teardown existed because a revoke and a network drop look IDENTICAL at the instant of disconnect, and stopping was the safe reaction that made "revoke stops the music within a second" true. Decision (Tim): revoke does not need to cut the CURRENT track's already-buffered audio. It needs to cut everything NEW. A revoked device may finish what ExoPlayer already buffered; it may not start the next track, browse, search, or fetch art or any new bytes. Sound against the real threats (lost phone, removed guest) - they hear the tail of one song, nothing more - and revoke never stopped a determined client from capturing the current stream anyway. The key: a revoke and a switch look identical at disconnect but DIVERGE ON RECONNECT - a switch reconnects, a revoke is denied. So the phone stops guessing at disconnect time. Implementation (client only, no wire change): - The shell no longer calls stop() on host:disconnected. It keeps the player and the queue and proactively reconnects. - The shim ALREADY reconnects on demand (every request awaits ensure()), so ExoPlayer's next chunk request rides through the blip - a switch is a stall the buffer usually hides; a revoke is denied and the buffer starves. - STARVATION net: if the player sits buffering (isBuffering true - which distinguishes a starve from a user pause) with position frozen for 15s while disconnected, the buffer ran dry and we cannot get back in - stop, and say "lost the connection" (NOT "revoked": from the phone a revoke and a tunnel are the same, per the 2026-07-14 background entry). VERIFIED on the TCL: played a track, revoked it from the dashboard. host:revoked killed the live connection, gate:deny {reason: device-revoked} denied the reconnect (NEW access cut off), and the player kept playing its buffer (the whole small track was buffered) - exactly the policy. Playing-to-end-of-a-buffered-track and the wifi<->cellular switch were verified separately (switch on the Pixel; the starvation timeout is reasoned from isBuffering + frozen-position and observed only indirectly - a large FLAC revoked mid-buffer is the case to confirm). The acceptance test in CLAUDE.md changed accordingly: "revoke stops the music within a second" -> "revoke cuts all NEW access within a second; the current track may finish". ## 2026-07-14 - ffmpeg SPIKE: the transcoder was the easy part; delivery is a client+shim project Tier: T1 (host groundwork landed; the shippable feature is deferred with findings) Context: the folder-first strategy named "ffmpeg in the folder adapter" the highest-leverage move, because transcoding (capping a FLAC for cellular) is the main thing a server connector had over a folder. Spiked it. What the spike PROVED, and it is the cheap half: - The folder adapter can transcode. Behind the existing `format`/`bitrate` params it spawns ffmpeg and streams stdout. Measured FLAC -> mp3@128k at ~7x smaller, ~50x realtime; opus@96k ~14x. If ffmpeg is absent it falls back to raw bytes - never an error - so the box degrades to exactly the pre-spike behavior. Tested. - Image cost is NOT the apt `ffmpeg` metapackage (466MB, nearly doubles the image). A per-arch STATIC ffmpeg binary is ~40MB (amd64) / ~19MB (arm64) compressed, fetched in the Dockerfile. ~10% image growth, not 2x. What the spike DISCOVERED, and it is the real cost - the phone cannot receive a transcode today, for two reasons: 1. The client never REQUESTS one. `media.stream` carries `format`/`bitrate`, but the worklet/shim never set them. (This was always true; the "bitrate adapts to network" entry of 2026-07-13 was a plan, never built on the client.) 2. THE SHIM IS BUILT ON BYTE RANGES, and a transcode has none. worklet/shim.js reports the track's exact size as content-length, answers 206 with content-range, and ExoPlayer seeks by byte offset - all of which require stable byte offsets that a transcoded stream does not have (byte 5,000,000 of the mp3 does not exist until ffmpeg makes it, and its size is unknown until it is done). Serving a transcode means a non-seekable 200 stream with no content-length, and seeking becomes "re-transcode from a time offset with -ss". That is a real rework of a PROVEN component. The reframing: "cellular transcoding" is NOT a folder-adapter change. It is a CLIENT + SHIM project (detect network -> request format/bitrate; shim serves a length-unknown non-seekable stream; seek = re-transcode with -ss), and it is SOURCE-AGNOSTIC - the same work lights up Navidrome and Jellyfin transcoding too, which have the identical latent gap. So it is more valuable than a folder feature, but it is also more than "add ffmpeg." Choice: - LAND the host-side transcoder now, as groundwork. It is correct, tested, gated behind params nothing sends yet, and harmless (inert until requested). - Do NOT add ffmpeg to the shipped image yet - 40MB of dead weight until the client can ask for a transcode. Add it together with the client+shim work. - Track "cellular transcoding" as its own milestone (client network policy + shim transcode-mode), NOT as a folder task. It is the honest next step, and it benefits all three sources at once. ## 2026-07-14 - Folder is the product; a connector inherits a server, it does not fetch music Tier: T1 (strategy / roadmap direction, no wire change) Context: with the folder adapter now reading tags, Tim asked the sharp question - if every music server just points at folders on disk, and we can point at those same folders, what does connecting to Navidrome/Jellyfin/Subsonic actually BUY over folder mode? Worth writing down before we spend effort adding more connectors. The framing that settles it: **a connector does not fetch the music - the bytes are in folders either way. It inherits the server's LIBRARY MODEL and SERVICES.** Concretely, what a connector gives that a folder does not, ranked by how defensible each is: 1. TRANSCODING. Navidrome/Jellyfin transcode FLAC -> a capped bitrate on the fly; the folder adapter ships no ffmpeg, so a raw-FLAC folder over CELLULAR is ~1GB/album. For "playable anywhere" that is the case that matters. BUT it is replicable: ffmpeg in the host image (already a backlog item) would give folder mode transcoding too, which would erase this advantage. This is the biggest reason to connect AND the one we could most easily make moot. 2. INHERIT WHAT THE USER ALREADY CURATED. Someone running Navidrome has playlists, favorites, ratings, fixed artwork, a chosen album grouping. Connecting shows them THE SAME library they already see; a folder re-scan cannot inherit playlists or curation. This is the DURABLE reason connectors exist - consistency with an existing setup - and folder mode structurally cannot cover it. 3. MUSIC THAT IS NOT A MOUNTABLE LOCAL FOLDER. We hit this with Nextcloud (files live in its data dir; folder mode needed a bind-mount). Plex/Jellyfin can span network shares or cloud mounts the host cannot easily reach. The server abstracts "where the bytes physically are." 4. The server already scanned/indexed and watches for changes; folder mode re-scans at boot and needs a manual Rescan. The counter-evidence, measured on Tim's own box: his Jellyfin GROUPED THE SAME FILES WORSE than folder mode - 10 folder-lumped "albums" (one of 667 tracks) vs folder mode's 604 clean tag-grouped albums (which matched Navidrome within a few percent). So for a plain, well-tagged local folder, the connector was strictly worse EXCEPT it can transcode. That is the whole tradeoff in one example. Decision / direction: - **Treat folder mode as the product, not the fallback** (the code has always hinted this: "the fallback and arguably the real product"). It is the only source that works for 100% of users, including everyone with no server. - **The connectors worth keeping are the ones that give something a folder cannot:** Subsonic (cheap, broad, matches what server-runners already see) and Jellyfin (Start9 has nothing else - DECISIONS 2026-07-14 platform survey). Be skeptical of chasing the bespoke long tail (Koel/Polaris/mStream) - the compatibility roadmap in TODO.md ranks them low for this reason. - **The highest-leverage investment is ffmpeg in the folder adapter**, because transcoding is connectors' biggest edge and adding it to folder mode gives cellular playback to EVERYONE, not only to people who run Navidrome. Pair it with .m3u playlist reading and folder mode covers most of what connectors do. Net: the value-add of a connector is "inherit my existing server's playlists/curation/transcoding," not "get my music." Build breadth of connectors only where they deliver that; otherwise pour effort into making folder mode great. ## 2026-07-14 - The app shows the server's OWN name, not the source KIND Tier: T1 (app UI + adapter stats field; no wire break - stats is free-form JSON) Context: the source indicator first showed the KIND ("Subsonic"), which is a lie when the kind 'navidrome' is actually serving Nextcloud Music or LMS. Tim: "Should 'Subsonic' be the source when it's really Nextcloud?" Finding: we do not have to guess or sniff the URL. **The server names itself.** Every Subsonic response carries `type` ("navidrome", "nextcloud music", "gonic", ...) and usually `serverVersion`; Jellyfin's System/Info/Public carries ProductName ("Jellyfin" vs "Emby Server") and Version. The adapters capture these (Subsonic off any response; Jellyfin off one unauthenticated call at scan) and report a `sourceName` in stats(). Choice: the app prefers `sourceName` (the server's own name) and falls back to a coarse kind label only for an older host that does not send one - where 'navidrome' stays "Subsonic" (the honest umbrella, since the kind is shared across all Subsonic servers). Verified on the TCL against the real Umbrel: the header reads "Nextcloud Music" while serving Nextcloud, "Folder" on the folder source. Consequence: this is also how an eventual Emby adapter will label itself without a new source kind - ProductName already says "Emby Server". ## 2026-07-14 - The folder adapter reads TAGS, and infers albums from them Tier: T2. Proposal: proposals/2026-07-14-music-sources.md Context: the folder adapter listed filenames and nothing else. It is the DEFAULT for anyone without Navidrome - a store install lands here - so "a library of filenames" was the first impression of the whole app for exactly the people with no server. Choice: parse tags with `music-metadata` (MIT: ID3, Vorbis, MP4, RIFF). We are not writing a tag reader; a decade of other people's broken tags is baked into that library and none of it into ours. The interesting part is not reading a tag, it is deciding WHAT AN ALBUM IS in a bare folder, and getting it wrong gives you 400 albums called "Greatest Hits" or one album per track. Three cases, in order: 1. An ALBUMARTIST tag - trust it completely. (albumartist, album) is the album, wherever the files sit. This is what merges Disc 1/ and Disc 2/ back into one album, and the only signal that survives a library organised by year or genre. 2. An album tag but NO albumartist - group by (DIRECTORY, album), never by (track artist, album). The second splinters every compilation into one album per guest performer. A directory is the strongest statement a folder makes about what belongs together. A compilation with differing performers and no albumartist is labelled "Various Artists" rather than whichever track was first off the disk. 3. No album tag - the DIRECTORY is the album. Untagged rips live in a folder named after the album roughly always. Other choices that matter: - Artwork is an adjacent cover.jpg/folder.jpg (usually the better scan, and one open() instead of parsing a 40MB FLAC) or the embedded picture, resolved LAZILY per album and cached - including MISSES, or an album with no art re-parses its files on every scroll. The scan skips covers entirely; holding 1358 embedded JPEGs to build a track list would cost hundreds of MB for art nobody asked to see. - A file with unreadable tags is STILL A TRACK (filename as title). Losing a playable file because we could not read its tags would make the tag reader worse than none. - trackId stays the PATH, not the tags. A library whose ids changed when someone fixed a title typo would orphan that track's play count (see the 2026-07-13 id entry). A NEW `groupId()` mints stable album/artist ids for a source that has none of its own; it is deliberately NOT a ledger key, so its derivation can change later. trackId's cannot. - A missing folder THROWS with the list of what the container CAN see, instead of reporting 0 tracks. "0 tracks" is indistinguishable from an empty library, and that is exactly the trap that cost an evening (below). ## 2026-07-14 - ONE CONFIG PER KIND; switching sources keeps the other's credentials Tier: T2 (persisted config shape). Proposal: proposals/2026-07-14-music-sources.md Context: Tim went Navidrome -> Folder -> Navidrome and the URL, username and password were gone. source.json held ONE flat config, so saving a folder overwrote the Navidrome credentials. Choice: source.json v2 is `{ version, active, sources: { navidrome, jellyfin, folder } }`. `active` is a POINTER; each kind keeps its own row. Flipping between them is free, the dashboard prefills any kind, and this is also the shape MULTIPLE SIMULTANEOUS SOURCES will need (the direction this is heading - see the combined-source trap in the 2026-07-13 trackId entry: a merged library needs a dedup story because trackId is source-scoped). Compat: v1 flat configs migrate on read (migrate()). Tim's Umbrel has a v1 file; it keeps working, password intact, and the credential surviving the upgrade is the whole point - losing it would take the library dark on a restart. Passwords still never leave the host (the view reports has-a-password, not the password); a blank field still means "leave it alone", now read from the STORE rather than the live adapter, which is what lets a kept Navidrome password survive while a folder is serving. ## 2026-07-14 - Jellyfin ships; PLEX does NOT (and not for legal reasons) Tier: T2 (new source kind). Proposal: proposals/2026-07-14-music-sources.md Context: the release phase named Jellyfin and Plex as the two server adapters to add alongside Navidrome. Plex was to be spiked first and dropped if it turned ugly. JELLYFIN: shipped. Same interface as Navidrome, mostly mapping. Username + password exchanged ONCE for an access token that does not expire until revoked; no cloud, no refresh, no third party. The token lives in memory; the PASSWORD is what we persist, because a cached token we could not refresh would strand the library on the first restart after the operator logged us out elsewhere. A stable deviceId (derived from libraryId) keeps Jellyfin's device list from filling with ghost PearTunes. Verified against a live server: auth, listing, album/artist detail, search, exact-byte streaming, Range 206, artwork, and the cold-restart id-rebuild path. PLEX: spiked, DECLINED. The reads are comparable to Jellyfin. The killer is auth: - There is NO local credential. Plex Media Server has no username/password endpoint; every authenticated read is gated by a token minted at plex.tv. So a headless host reading a disk three feet away must (a) run a browser-mediated plex.tv/link flow at setup, and (b) as of Sept 2025 keep a ROLLING 7-DAY cloud refresh loop alive for the life of the install (nonce -> sign an Ed25519 device JWT -> exchange), forever. - The only escape is `allowedNetworks` ("allowed without auth"), which means telling the user to WEAKEN their Plex server's security - a grotesque thing for THIS app, whose pitch is "do not expose your server", to ask. A dependency on plex.tv being reachable every 7 days, so the daemon can read a local file, is precisely the failure mode PearTune exists to abolish. We would be shipping it on purpose, in the always-on process. IMPORTANT, so nobody re-opens this on the wrong grounds: this is a decision on COST and ARCHITECTURE, NOT on legality. Plex published an official, versioned API in Sept 2025 and applauded the reverse-engineers; the ToS explicitly contemplates "client applications that communicate directly or indirectly with the Plex Solution"; music is EXPLICITLY exempt from the 2025 remote-playback paywall; and there is no enforcement history against clients (Plezy does exactly this in the open today). If we ever revisit - the argument for reach is real, Plex's install base dwarfs the others - do it after milestone 4, JWT-flow only, music-only (the paywall exemption is audio-shaped; video would invert the whole calculus), and never instruct anyone to set allowedNetworks. ## 2026-07-14 - The folder path is a CONTAINER path, so the dashboard must not guess Tier: T1 (dashboard + adapter). Proposal: proposals/2026-07-14-music-sources.md Context: Tim typed the path Navidrome uses on the HOST (/home/umbrel/umbrel/home/Downloads/music) into the folder field and got zero tracks. Correctly - that path does not exist INSIDE the container, only what is MOUNTED does (/music) - but "0 tracks" is indistinguishable from an empty library, so it reads as "this app is broken" rather than "that path is wrong". It cost a real evening. Two compounding facts made it worse: the compose mounted an EMPTY docker volume, so even the right path had nothing; and nothing anywhere said "container path". Choice: a free-text box the host cannot verify is the bug. 1. A folder BROWSER (/api/source/folders): the dashboard lists the directories the container can actually see, flags which ones contain music, and the operator PICKS one. The value that fills the box came from something that provably exists. Built with DOM nodes, not string concatenation - these names come off a filesystem, onto the page that holds the revoke buttons (see the stored-XSS entry). 2. Test on a missing path THROWS a sentence naming what IS visible, instead of "0 tracks". Test that reaches a real but empty folder says "reachable, but NO MUSIC in there" - zero tracks is not a pass. 3. The Umbrel compose mount was simply WRONG: ${UMBREL_ROOT}/data/storage/downloads is empty on a real Umbrel. Navidrome's own app mounts ${UMBREL_ROOT}/home/Downloads, which is where the files are (verified: 1357 tracks there, 0 in the old path). The compose now mounts that, defaulting the library to its music/ subfolder, with the whole Downloads dir visible so the picker can reach podcasts/ and audiobooks/ too. ## 2026-07-14 - The music source is DATA, not deployment Tier: T2 (new persisted config + new dashboard API) Context: preparing the community-store listing. A store install has no env vars, so it would land on the folder adapter - which has no tag reading - and the user would get a library of FILENAMES. Nobody installing an app from a store is going to hand-edit a docker-compose file to point it at their Navidrome. Choice: the source lives in the host's own data dir (source.json, 0600 - it holds a password), chosen in the dashboard. Precedence: source.json > env/CLI > folder. The operator's choice therefore SURVIVES a restart with different env vars, which is the whole point. Details that matter: - The adapter is swapped ATOMICALLY and only after the new one scans. Wrong credentials throw and the OLD source keeps serving: a library going dark is not an acceptable way to learn you mistyped a password. The dashboard has a Test button for the same reason. - media.js takes a getAdapter() GETTER, not an adapter. A connection outlives a source change, and a phone still streaming from the source you just switched away from is a bug you would not find for weeks. - A BAD saved source does not stop the host from starting. If it did, the operator would be locked out of the very dashboard they need to fix it. - The password is never sent to the browser (publicView). A blank password field on an already-configured source means "leave it alone", not "set it to empty". Verified on the Umbrel with a container that had NO Navidrome env vars: 0 tracks (folder) -> configured from the dashboard -> 1358 tracks, and it survived a restart. ## 2026-07-14 - The dashboard gets a LOCK, because the host must own the network Tier: T3 (auth gate). Proposal: proposals/2026-07-14-dashboard-auth.md The chain of facts, each one measured rather than assumed: 1. **The host needs `network_mode: host`.** Re-tested today on the Umbrel with the real image: under Docker's default bridge the firewall ADMITS the client (gate:allow-for-pairing) and the connection dies before the pair channel opens. Bridge NAT is a second layer of NAT; holepunching does not survive it. 2. **Host networking means Umbrel's app_proxy cannot front us.** The proxy is a container on a bridge network and cannot reach a service bound to the host's loopback. Umbrel's own host-networked apps (Plex, Home Assistant) skip the proxy and serve their UI straight onto the LAN, protected by their own login. 3. **The proxy was the only thing standing in for our missing auth.** The dashboard had none BY DESIGN (DONE 2026-07-13), which was fine while it was loopback-only. So the dashboard now has its own lock: PEARTUNE_PASSWORD (umbrelOS passes ${APP_PASSWORD}), a session cookie, a constant-time compare, and a 5-strike rate limit. THE RULE THAT MATTERS: **the host REFUSES TO START** if told to bind a non-loopback address with no password. Not a warning - an exit, before it joins the DHT. A warning in a log nobody reads is not a control, and every "expose it just for a minute" is how a revoke button ends up on an open port forever. Not in scope: TLS. On a home LAN the password crosses in the clear, which is what every other Umbrel app does; a self-signed cert would only train people to click through warnings. Say it in the README instead of pretending. ## 2026-07-14 - The host image gets its OWN package.json Tier: T1 (build) The image would not build at all: the repo root is the PHONE APP (React Native, Expo), it runs `postinstall: patch-package` for the app's expo-audio patch, and patch-package is a devDependency - so `npm ci --omit=dev` installed no patch-package and then ran it anyway. Exit 127, every time. `--ignore-scripts` is NOT the fix: it also skips the native postinstalls the hypercore stack needs, and you get a runtime that half-works and then aborts. Choice: host/package.json with the ELEVEN packages host/, client/ and protocol/ actually require. The image stops dragging React Native and Expo into a server that has no use for either. Keep the versions in step with the root by hand: same wire, and a skew between phone and host is a protocol bug waiting to happen. ## 2026-07-14 - Device and user naming: a device names ITSELF, an operator confirms Tier: T2. Proposal: proposals/2026-07-14-device-and-user-naming.md Context: two phones paired, and the dashboard showed two rows called "Android phone". Per-person grants existed and worked; the operator had nothing human to look at, so the feature was complete and unusable. Findings that made this smaller than it looked: - `deviceHello` ALREADY carries a label, the host already stores it, the dashboard already renders it. We were hardcoding "Android phone". Naming a device at pair time is a T1 with no wire change. - The host ALREADY has a people model (person rows, assign, revoke-person). What was missing was any way for the phone to SAY who it belongs to. Choice: no framing change (adding a field to deviceHello would break old peers - that is the T2/T3 line). Two new METHODS on the media channel instead, which carries {method, params} JSON already, so an old host answers ENOMETHOD and a new app degrades to "renaming needs a server update". The rules that let a client write into the host's authority store at all: 1. The caller is the NOISE-AUTHENTICATED connection. identity.set takes no device key - there is nothing to forge, and a device can only write its own row. 2. A device may NOT set personId. It CLAIMS a name; the operator confirms it. Today personId only drives revoke-by-person, so self-assignment would be harmless - but the moment per-person scopes, playlists or history exist, a device that can attach itself to any person by name is an escalation. Self-declared identity must not become authority. 3. A claim grants nothing. It is cosmetic until confirmed. 4. Names are sanitized at the HOST (trim, cap 64, strip control chars) and escaped at the render. Re-pairing an already-granted device now UPDATES its label (it can rename itself over the media channel anyway, so refusing here would protect nothing and only surprise people) but never touches personId or the claim. ## 2026-07-14 - STORED XSS ON THE DASHBOARD (found while building naming; fixed) Tier: T3-adjacent (the dashboard is the control plane) The device label arrives in deviceHello from ANY device that reaches the pairing window, and host/ui/page.js interpolated it RAW into innerHTML - and into a revoke() onclick attribute. That is a stored XSS on the page that holds the revoke buttons and the pairing QR. It predates this work: with the label hardcoded to "Android phone" nobody would have noticed, and the naming feature is what made it obvious (people type names). Fixed: every device- or operator-supplied string is escaped at render; the revoke button now carries the device KEY only and looks the label up from data. A test asserts the escaper is used, because this is the kind of fix that quietly rots. ## 2026-07-14 - An artist with NO ALBUMS is not empty, and search groups collapse Tier: T1 (host adapter + UI) Context: Tim searched "krutch", got artist results, and long-pressing one to add it to the queue failed with "nothing to play there". Opening it showed no albums and no songs. Root cause, and it is a Navidrome data fact, not a bug in our code: **Navidrome mints an artist row for every COMPOSITE TAG string it meets** - "Thousand Foot Krutch/COFER", ".../Karmageddon", ".../Red". A search for "krutch" returns ONE real artist (18 albums) and NINETEEN participant rows with `albumCount: 0`. They have songs; they have no albums of their own. Our artist page is built on "an artist IS its albums" (one getArtist call), so those rows were a dead end. Choices: 1. An album-less artist falls back to its SONGS. There is no getSongsByArtist in Subsonic and getTopSongs answers empty for these (tried it) - `search3` on the exact name works, filtered to an EXACT artist match, because a substring search for "Thousand Foot Krutch" would drag in the primary artist's whole catalogue. 2. **Search ranks artists with albums FIRST.** The server's own order buried the one artist you were obviously looking for underneath nineteen featured-on rows. 3. The participant rows carry no coverArt (Navidrome answers them with its default white-star image; a wall of those looks worse than our own placeholder), and no "0 albums" label - a true thing to say, and a useless one. Search results are now COLLAPSIBLE GROUPS with counts (Artists 20 / Albums 18 / Songs 50). Each opens independently - this is deliberately NOT an accordion, since you often want the artists AND the songs, and closing one to see the other is the same tedium in a different shape. A group with <= 5 hits opens itself: making someone tap to reveal two results is a worse tax than the scrolling was. Also: a transient failure ("nothing to play in X") is now a TOAST, not a red banner nailed to the top of the screen until something else clears it. ## 2026-07-14 - The queue is a TAB, and playIndex must not announce Tier: T2 (native patch + player) Context: Tim asked for a way to play or queue an album/artist without drilling into its track list, and then asked whether the queue indicator belonged somewhere more persistent than the player. Choice: a fourth navbar tab (Library / Queue / Settings / About) carrying a COUNT BADGE, plus Play / Shuffle / Add-to-queue on the album and artist screens and on a long-press of any tile. The mini-player's own queue pill was REMOVED once the tab existed - two copies of the same number, inches apart, is noise. Honest caveat, and it is the interesting one: **stopping playback still throws the queue away.** The queue IS the playlist inside ExoPlayer; there is no persisted queue that outlives it. So the Queue tab is empty whenever nothing is playing. If that becomes annoying, the fix is queue PERSISTENCE in the worklet (restore on launch), not a different place to put the button. ADD TO QUEUE required a new native method: `addQueueSources` in the expo-audio patch. It is addMediaSources, NOT setMediaSources - re-handing ExoPlayer the whole playlist would reset the current item and restart buffering, i.e. interrupt the song the user is listening to, which is the exact opposite of what "queue this for later" means. No prepare() either, unless the player is IDLE (the "nothing is playing, queue an album" case), because prepare() on a live player stutters. Verified in the dex (`strings | grep QueueSources`) and on the phone: queued an album mid-song, the track kept playing, the counter went 1 -> 2. THE BUG THAT COST THE MOST, and it is worth writing down because it looks like it should work: **playIndex (tap a queued track to jump to it) must NOT call announce().** setActiveForLockScreen tears the MediaSession down and builds a new one, and doing that in the same breath as a seek loses the AUDIO FOCUS with it. The jump appeared to work - the right track loaded, the UI updated - and it landed PAUSED, silently. The status listener already announces when it sees the index move (that is how gapless advance updates the lock screen), so the correct code is: seek, play, and let it notice. ## 2026-07-14 - Navidrome DOES have an all-songs endpoint. The Songs view is back Tier: T1 (host adapter) Context: this repo has said since milestone 2 that "Subsonic has no all-songs endpoint", which is why the flat track list was abandoned (it could only ever show the first page of albums walked) and albums became the way in. Tim asked for a Songs view anyway, so I went and asked the actual server. Finding: **Navidrome (OpenSubsonic) answers `search3` with an EMPTY query as "everything", and it pages by `songOffset`.** Measured against the real library: all 1358 songs, and songOffset=1000 returns the expected rows. So a Songs view is a paged list, not a 60-call album walk. The old claim was true of strict Subsonic and false of the server we actually target. Choice: `list({type:'tracks'})` uses the empty-query search3, and FALLS BACK to the album walk if the server refuses. The fallback matters: an empty first page is indistinguishable from "not supported", so an empty first page is treated as unsupported rather than as "this library has no music". Past the first page, empty means the end - do not re-walk. What we still do NOT get: sorting. The order is the server's (roughly artist / album / track). Sort-by-title would mean pulling all 1358 rows into the phone first, and we are not doing that for a scroll. ## 2026-07-14 - Grid density is ONE control, and 4-up is not on it Tier: T1 (UI) Context: Tim proposed a grid/list toggle AND a 2/3/4-per-row picker. Choice: one control, cycling List -> 2-up -> 3-up. No 4-up. Why: "grid or list" and "how many per row" are the same axis - density - and splitting them gives four states to explain and test for one decision. 4-up on a phone is an ~85px cover, too small to recognise the art, which is the only reason a grid exists at all; a list serves that density better and shows the full title. Consequence worth having: the ART SIZE now follows the density (list 120px, 2-up 500px, 3-up 350px). Covers were being fetched at a flat 300px into a ~500px 2-up tile, so they were already slightly soft, and a 500px cover behind a 52px list row is bytes crossing a P2P link for nobody. The UI composes the art URL itself (the worklet hands it a base URL) so changing density does not re-list the library. ## 2026-07-14 - Predictive back is OFF. It breaks the back button Tier: T1 (Android) Context: Android 14+ predictive back (`android:enableOnBackInvokedCallback="true"`) animates a peek of what is behind the app during a back gesture. Expo scaffolds it as "false"; I turned it on and tested it on the TCL. Result: **it broke the back button.** With the flag on, the first back press from the About tab CLOSED THE APP instead of popping back to Library. React Native 0.81's BackHandler is not routed to the platform's new OnBackInvokedCallback here, so the system takes the press and finishes the Activity - and our entire nav contract (shell:navState + a 'back' event, with the shell only swallowing the press when the UI has something to pop) is bypassed. Choice: reverted. The flag stays "false" until React Native routes the new callback to BackHandler. Worth being clear about what we are NOT missing: predictive back could never have animated OUR screens anyway. PearTune is one Activity hosting a WebView, and the nav stack (album, artist, sheets) is React state the system knows nothing about. Animating those needs OnBackAnimationCallback, which RN does not expose. The only thing the flag buys is a nicer animation when back CLOSES the app - and it charged a broken back button for it. ## 2026-07-14 - The background disconnect is EXPECTED. Reconnect on demand Tier: T1 (client behavior) Context: Tim noticed the app loses its connection to the host about a minute after going to the background while idle. Measured on the TCL: the host logs `media:channel-closed` roughly 20 seconds after the app is backgrounded. Why it happens: Android suspends a backgrounded app that is not holding a foreground service. It stops sending keepalives, and the link times out. This is not a PearTune bug and cannot be "fixed" at our layer. What is NOT broken, and this is the important measurement: **while a queue is loaded - playing OR PAUSED - the link survives.** The media session keeps the process alive. Verified: paused with a queue, backgrounded 75 seconds, pressed PLAY on the media keys, audio resumed over the still-live connection. The case that actually matters for a music player already works. Choice: let the idle link die, and reconnect ON DEMAND rather than holding it open. 1. A permanent foreground service WOULD keep the socket alive, and we are not doing that: a permanent notification and a battery cost, so an idle app can hold a connection nobody is using. 2. `ensureConnected()` in the worklet revives the link for ANY caller that needs it, behind a single-flight promise (a screen coming back fires albums + artists + a dozen art requests in one tick; they must share one dial, not race). 3. The shell fires `app:active` on resume, so the reconnect starts before the user asks. In practice they never see it. 4. The shim's request handler ALSO calls it. That is the path nothing else can cover: phone asleep, queue paused, link dead, user presses play on the LOCK SCREEN. No UI is awake to help; the loopback request itself has to heal the connection. The trap this exposed: **the shim must survive a reconnect and KEEP ITS PORT.** It listens on port 0, and the player is holding `http://127.0.0.1:/t/` URLs for the whole queue. The first version of `reconnect` tore the shim down and built a new one - a new port - so a paused queue would have resumed into a dead socket. The shim now takes a replaceable client (`setClient`) instead. Also: we no longer tell the user their access "may have been revoked" when the link drops. From the phone, a revoke and an Android suspend look identical, and that sentence is alarming when the true answer is "you locked your phone". It is now only said after a reconnect has actually failed. ## 2026-07-14 - Shuffle and repeat are INDEPENDENT, and both may be on Tier: T1 (UI) Context: Tim noticed both can be enabled at once on an album and asked whether that is expected. It is - this is a NOTE, not a bug, and it is written down so nobody "fixes" it later. Choice: they stay orthogonal. Confirmed with Tim 2026-07-14. Why: they answer different questions. Shuffle decides the ORDER; repeat decides what happens at the END. Every mainstream player (Spotify, Apple Music, YouTube Music) allows the combination, and shuffle + repeat-all - shuffle the album and loop it forever - is one of the most common ways people actually listen. Making them exclusive would also mean fighting the platform: ExoPlayer exposes `shuffleModeEnabled` and `repeatMode` as two independent properties, so we would be writing code to take a feature away. The four states, all coherent: shuffle off + repeat off -> album in order, stops at the end shuffle on + repeat off -> album shuffled, stops at the end shuffle on + repeat all -> shuffled, loops forever shuffle on + repeat one -> loops this track; shuffle picks what comes next whenever the listener does skip ## 2026-07-14 - The theme preference lives in the WORKLET, not localStorage Tier: T1 (local state) Context: every sibling app (PearPetal, PearList, PearCircle) keeps the theme preference in the WebView's localStorage and reads it synchronously in main.jsx before the first paint. Choice: PearTune keeps it in the worklet, in `settings.json`, next to the device identity and the paired host. A deliberate deviation from the suite. Why: it is the only way to get a FLASH-FREE cold start here. The shell boots the worklet BEFORE it loads the WebView, so it can read the preference, resolve 'system' against `Appearance`, paint its own chrome (status bar + the strip under the WebView) correctly, and hand the WebView a document that already carries the right `data-theme`. With localStorage the shell cannot see the preference at all (it is inside the WebView it has not created yet), which is exactly why PearPetal needs a separate AsyncStorage cache of the resolved theme to avoid the flash - two stores for one fact. One store, read once, at the only moment that matters. Cost: settings are gone if the app's data is cleared - the same wipe that already takes the device identity and forces a re-pair, so nothing new is at risk. Verified on the TCL: preference Light, force-stop, relaunch - the FIRST painted frame (the "Starting…" screen, before the library loads) is already light. ## 2026-07-14 - The navbar stays visible during a drill-down Tier: T1 (UI) Context: PearList hides its tab bar when a list is open, treating a drill-down as a full-screen takeover. Choice: PearTune keeps the navbar visible inside an album or an artist. Why: the dock is one fixed object - the player sits ON the navbar. Hiding the navbar under an album would drop the player ~64px down the screen mid-song, and raise it again on the way back. A music app's transport does not move. ## 2026-07-14 - Gradle must never cache the JS bundle task Tier: T1 (build) Context: PearTune's whole UI is an ASSET (`assets/index.html`, built by esbuild), not a JS source file - and so is the Bare worklet bundle. Choice: `outputs.upToDateWhen { false }` on `createBundle*JsAndAssets` in `android/app/build.gradle`. Why: gradle's up-to-date check does not notice when only those assets change. It skips the bundle task, repackages the PREVIOUS bundle, and the APK silently ships a STALE UI - you rebuild, install, screenshot, and are looking at old code with no error anywhere. Found the honest way: a CSS fix that would not apply, twice. Cost: ~30s per debug build. Cheaper than shipping the wrong bundle. ## 2026-07-13 - NOT WebRTC. HyperDHT for transport, loopback HTTP for the player Tier: T3 (transport) Context: WebRTC comes up a lot in the Pear/Holepunch space around streaming media, so: should PearTune use it instead of "HTTP"? First, a category error worth naming: **the HTTP in PearTune never touches the network.** The shim runs on 127.0.0.1 between our Bare worklet and Android's media player - both on the same phone. The phone-to-host transport is already P2P (HyperDHT + UDX + Noise). So WebRTC would not replace the HTTP; it would replace HyperDHT. Choice: keep HyperDHT for transport and the loopback HTTP shim for the player. Why WebRTC is a downgrade HERE (it is a fine technology, wrong fit): 1. **It reintroduces servers.** WebRTC needs signaling to exchange offers/ICE candidates, plus STUN, plus TURN when holepunching fails. TURN is a relay - a server you run and pay for, carrying user traffic. "No servers" is the whole pitch. Holepunch's DHT does discovery + holepunching with zero infra. 2. **Its media path is the wrong shape.** WebRTC media channels are RTP: built for realtime conversation, and they DROP PACKETS to protect latency. Correct for a video call, wrong for a FLAC, where we want exact bytes. You would end up on DataChannels instead - a reliable ordered byte stream, i.e. exactly what UDX already gives us, with more overhead and a signaling dependency. 3. **It would destroy the auth model.** Everything here rests on Noise authenticating the far end AS A PUBLIC KEY, which is what lets the host allow-list device pubkeys with no bearer token anywhere. WebRTC identity is DTLS fingerprints exchanged over whatever signaling you built, so pairing, grants and revocation would have to be rebuilt on a weaker foundation. When WebRTC WOULD be right, and we should revisit then: (a) a BROWSER client - browsers cannot open raw UDP sockets, so WebRTC is the only P2P transport available there; (b) genuinely realtime audio (listen-together, broadcast, calls), which is what RTP is actually for. Neither is v1. Alternatives to the loopback shim (the real competitor, not WebRTC): a custom ExoPlayer DataSource in Kotlin reading straight from the worklet - no socket, no cleartext exemption, less copying. Rejected for now: real native code per platform plus a Kotlin-to-Bare bridge, to replace a shim that is already proven. Revisit if on-device profiling shows the HTTP hop actually costs battery or CPU. ## 2026-07-13 - Protomux message order lives in ONE shared file Tier: T3 (wire format) Context: Protomux assigns each message a type id by REGISTRATION ORDER - first `addMessage()` is type 0, next is type 1. Host and client each registered their own, in different orders (host: `paired` then `deviceHello`; client the reverse). Both ends decoded every frame as the wrong type. It fails SILENTLY: the frames arrive, decode into garbage, and the handler you expected simply never fires, so pairing hung with no error on either side. The media channel had the same latent mismatch. Choice: `protocol/channels.js` owns the registration order and both sides build channels through its factories. Hand-rolling `mux.createChannel` + `addMessage` for a PearTune channel is now a bug by definition. New message types append to the END of the list; inserting in the middle silently renumbers the wire. Consequences: the ordering can no longer drift between host and app, which are in different runtimes (Node and Bare) and will be updated on different schedules. ## 2026-07-13 - Pairing dials the host by key; there is no rendezvous topic Tier: T3 (pairing flow - SUPERSEDES §2 of proposal 2026-07-13-wire-protocol) Context: the proposal copied PearCircle's seeder QR pairing, where phone and host meet on a Hyperswarm rendezvous topic derived from a one-time `rv`. That did not survive contact with the code: Hyperswarm creates its OWN HyperDHT server and listens on its keypair, so a host running both a Hyperswarm (for pairing) and its own `dht.createServer` (for media) under one identity had two servers fighting over the same keypair, and deadlocked. Choice: drop the rendezvous entirely. The QR already carries the host's public key, so the phone DIALS THE HOST DIRECTLY by key. `rv` survives as a one-time pairing TOKEN, presented in the hello to prove the device actually saw the QR. The firewall gains a narrow exemption: while a pairing window is open it admits an ungranted device, and `_onconnection` then offers it the pair channel ONLY, never the media API. Alternatives: give the pairing swarm a separate keypair (then the phone cannot verify the host against the QR); keep Hyperswarm and drop our own server (loses the `firewall` hook, which is the entire auth design). Consequences: STRICTLY STRONGER than what it replaces. Dialing a HyperDHT key means Noise authenticates the far end AS that key, so an impostor who photographed the QR cannot answer the call at all. The seeder's "verify the remote pubkey matches the QR" guard stops being a check we must remember to write and becomes a property of the transport. Also simpler: no Hyperswarm in the host at all until the ledger needs one in milestone 3. Why the seeder differs: there the PHONE held the secrets and the seeder was anonymous, so a rendezvous was the only way to meet. Here the host has the stable public identity. Do not cargo-cult the seeder's flow into a third app without checking which side is anonymous. ## 2026-07-13 - Bitrate adapts to the network, with a user override Tier: T1 (client policy; the wire already carries the params) Context: a FLAC library over cellular is roughly 300MB an album and will stutter, but always transcoding wastes fidelity on the wifi where most listening happens. Choice: original quality on wifi, capped bitrate on cellular, overridable in settings. `media.stream` carries `format` / `bitrate`, which the Navidrome adapter passes straight through to Navidrome's own transcoder. Alternatives: always-original (burns data), one fixed user setting (wastes either data or fidelity, since it cannot know the network). Consequences: free for Navidrome sources. The **folder adapter has no transcoder to delegate to**, so a raw FLAC library over cellular is the one case that still costs real data - the client must warn on that combination. ## 2026-07-13 - Revoke is an access control, not a history eraser Tier: T2 (ledger semantics) Context: when a device is revoked, do its `count:` play-count rows leave the ledger with it? Choice: they stay, and keep contributing to totals. Play history belongs to the user and should not evaporate because they retired an old phone. Alternatives: purge on revoke (means removing an Autobase writer, a much bigger hammer, and it makes revoke irreversibly destructive); purge only for people and not devices (the ledger would have to know which grants were "you" vs "a guest"). Consequences: a revoked device cannot reach the library, but its historical contribution to play counts persists. Say so in the revoke UI so it is not a surprise. ## 2026-07-13 - The host is a full Autobase writer, not a blind seeder Tier: T3 (trust model) Context: PearCircle's seeder is deliberately blind - it stores encrypted blocks it cannot read. Should PearTune's host be the same? Choice: full writer. It never writes state rows, but it participates in the ledger so that (a) your resume position is reachable when every phone is off, and (b) a new device can be admitted as a writer without another phone awake. Alternatives: blind seeder (cannot admit writers, so pairing a second device while the first phone is off leaves it stuck read-only); blind seeder plus a narrow admission side channel (new protocol surface in the most security-critical part of the design, which is exactly where not to be clever). Consequences: the host can read listening history. Acceptable: it is a machine you own that already holds the music in the clear, and listening history is far less sensitive than the location trail that motivated blind seeding in PearCircle. This asymmetry is deliberate and must not be cargo-culted back. ## 2026-07-13 - Track ids are source-scoped; a source switch orphans state Tier: T2 (key derivation) Context: `trackId = z32(blake2b('peartune/track/1' || libraryId || sourceKey))`, where sourceKey is the Navidrome id or the library-relative path. So the same file reached two ways hashes two ways. Choice: accept it for v1 and warn in the UI **before** a source switch, not after. A tag-matching remap tool is the escape hatch if it bites. Alternatives: content-hash the audio (bulletproof, but a full read of a large FLAC library on a Pi-class Umbrel is slow and disk-punishing); tag-derived ids (no rescan cost, but they collide on compilations and live albums, and break when you fix a typo in a tag). Consequences: switching a library from raw folder to Navidrome loses resume positions, favorites and play counts. TODO for the remap tool. ## 2026-07-13 - Build our own host on hyperdht; do NOT depend on holesail Tier: T3 (auth gate + wire protocol) Context: holesail.io is a P2P TCP/UDP proxy on the same Holepunch stack, and looked like it might save us the entire host transport. Choice: build our own host daemon directly on MIT `hyperdht`, using `dht.createServer({ firewall(remotePublicKey) })` against a host-local allow-list. Borrow holesail's *shape* (invite encoding, mode header) clean-room, take none of its code. Alternatives: depend on / vendor / fork holesail. Consequences: rejected on two independent grounds, either of which suffices. (1) **It cannot do what we need.** Its "private" mode admits only a client whose keypair IS the server's own keypair, derived from a shared seed; its newer `@holesail/invite` capability is still one bearer token shared by every client. No per-device identity, and the only revocation is rotating the seed, which kicks everyone. We require per-device and per-person grant/revoke. (2) **Licensing.** holesail and `@holesail/*` are AGPL-3.0, holesail-server / -client are GPL-3.0. The suite is MIT. The wheel we would be "reinventing" does not exist: what holesail offers is a shared-password tunnel, and what we need is authorization. Noise already authenticates every HyperDHT connection, so the host learns the client's real device pubkey for free and there is no bearer token anywhere in our design. ## 2026-07-13 - Normalized host API, not a raw port tunnel Tier: T3 (wire protocol) Context: the cheap path is holesail-style - tunnel Navidrome's port and let the phone speak HTTP to it. Choice: the host exposes a normalized `peartune/media/1` API over the authenticated stream. Two source adapters (Navidrome/Subsonic, raw folder) sit behind it, and the app cannot tell them apart. Alternatives: raw port tunnel. Consequences: a scoped guest never gets handed Navidrome's whole surface and its credentials; scopes are enforceable per request; and the app never learns to speak Subsonic, which keeps the raw-folder adapter a first-class citizen instead of an afterthought. Costs us an API surface we would not otherwise have written.