Ctrl K
ComunidadGuías…

DECISIONS.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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/<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
<name>" / "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@<droplet> 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/<libraryId>/<trackId> and /art/<libraryId>/<coverId>, beside the unchanged single-host /t/<id>, /art/<id>.
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/<libraryId>/ 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 <data>/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 <pkg-id>.embassy:<port> (Start9) and
localhost:<port> (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
<data>/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 <svg> 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-
<svg> 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 <img>; 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
<select> + an asc/desc toggle shown only when reversible); on a view the source cannot sort it
becomes a faded disabled stub rather than vanishing, so the row does not reflow (the same call
the density button makes). Sort choice is per-view and persists across view switches; changing
it reloads that view from cursor 0 (new params passed straight to the loader, not read back
from not-yet-committed state).
Verify: 266 tests (test/sort.test.js brute-checks the comparators + FULL_SORTS; folder sorts
real fixtures; subsonic/jellyfin stub _call to pin the type/SortBy mapping + the songs-degrade;
unknown sort key falls through to default order). `npm run verify` green.
ON-DEVICE (TCL + Umbrel/Navidrome, 2026-07-17): the hardware pass CAUGHT A REAL BUG the unit
tests could not - the worklet (src/bare.js) built the library.list params EXPLICITLY as
`{type, cursor, limit}` and DROPPED sort/order, so the control changed the pill but the list
never reordered. The wire (client.list forwards the whole params object) and the host were both
correct; only the worklet handlers were narrowing. Fixed: tracks/albums/artists handlers now
forward sort/order. After the fix, confirmed on the TCL: Albums -> sort by Year reordered to
oldest-first (Led Zeppelin 1969, II, III, IV...); the Name/Artist/Year picker shows exactly the
Subsonic album capability (no track-only keys leak in); the Songs view correctly shows the
disabled stub (Subsonic has no all-songs sort). Also fixed a layout clip found in the same pass:
the sort <select> ("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:<port>/t/<id>` 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.